mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-11 06:48:39 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39d01f275 |
@@ -173,7 +173,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Test WebUI
|
- name: Test WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
run: bun run test:coverage
|
run: bun run test
|
||||||
|
|
||||||
- name: Build WebUI
|
- name: Build WebUI
|
||||||
working-directory: webui
|
working-directory: webui
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.
|
|||||||
|
|
||||||
## 🌐 WebUI
|
## 🌐 WebUI
|
||||||
|
|
||||||
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, temporary chats, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
|
||||||
@@ -250,10 +250,9 @@ The WebUI ships **inside the published wheel** with no separate frontend build.
|
|||||||
Use it to:
|
Use it to:
|
||||||
|
|
||||||
- keep separate topics for different tasks and projects;
|
- keep separate topics for different tasks and projects;
|
||||||
- use temporary chats when a conversation should not be saved to history or memory;
|
|
||||||
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
|
||||||
- switch models and workspaces without leaving the conversation;
|
- switch models and workspaces without leaving the conversation;
|
||||||
- configure providers and chat channels, connect Apps, discover Skills, and manage Automations from one place.
|
- configure providers, chat channels, Apps, Skills, and Automations from one place.
|
||||||
|
|
||||||
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ nanobot agent -m "Hello!"
|
|||||||
Install Langfuse:
|
Install Langfuse:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot plugins enable langfuse
|
python -m pip install langfuse
|
||||||
```
|
```
|
||||||
|
|
||||||
## Minimal working example
|
## Minimal working example
|
||||||
|
|||||||
@@ -549,7 +549,7 @@ This recipe applies after the agent works and you want observability for OpenAI-
|
|||||||
Install the optional package in the same Python environment that runs nanobot:
|
Install the optional package in the same Python environment that runs nanobot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nanobot plugins enable langfuse
|
python -m pip install langfuse
|
||||||
```
|
```
|
||||||
|
|
||||||
Set the environment variables before starting nanobot:
|
Set the environment variables before starting nanobot:
|
||||||
|
|||||||
@@ -270,12 +270,6 @@ http://127.0.0.1:8765
|
|||||||
|
|
||||||
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
If accessing from another device, bind the WebSocket channel to `0.0.0.0` and set `token` or `tokenIssueSecret`. The WebSocket channel refuses public binds without a token or token issue secret.
|
||||||
|
|
||||||
| Symptom | Check |
|
|
||||||
|---|---|
|
|
||||||
| A temporary chat disappeared after a reload or reconnect | This is expected. Temporary chats exist only for the current WebUI connection and are not saved to history or memory. Use a regular topic for anything you need to retain. |
|
|
||||||
| A skills.sh install says that `npx` is required | Install Node.js with `npx` on the gateway machine, or choose a SkillHub skill that does not require `npx`. |
|
|
||||||
| A remote browser says skill installation is disabled | Install from a same-machine WebUI. For a private deployment where every authenticated user is trusted to install third-party skill instructions or scripts, explicitly enable `tools.webuiAllowRemotePackageInstall`. |
|
|
||||||
|
|
||||||
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
See [`webui.md#lan-access`](./webui.md#lan-access) for LAN setup and [`../webui/README.md`](../webui/README.md) for frontend development.
|
||||||
|
|
||||||
## Chat App Problems
|
## Chat App Problems
|
||||||
|
|||||||
+19
-68
@@ -1,10 +1,10 @@
|
|||||||
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
# Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
|
||||||
|
|
||||||
<!-- Meta description: Run nanobot from a browser WebUI with persistent and temporary chats, visible tool activity, workspace controls, Apps, skill discovery, settings, and Automations. -->
|
<!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
|
||||||
|
|
||||||
The WebUI is nanobot's browser workbench for persistent topics, temporary
|
The WebUI is nanobot's browser workbench for persistent topics, visible
|
||||||
chats, visible agent activity, workspace controls, Apps, skill discovery,
|
agent activity, workspace controls, Apps, Skills, settings, and Automations in
|
||||||
settings, and Automations in one place.
|
one place.
|
||||||
|
|
||||||
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
|
||||||
the `webui/` source directory when you are changing the frontend itself.
|
the `webui/` source directory when you are changing the frontend itself.
|
||||||
@@ -72,14 +72,14 @@ This path avoids hand-editing `config.json` for normal setup. Use the reference
|
|||||||
|
|
||||||
| Area | Use it for |
|
| Area | Use it for |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Topics | Start persistent topics or temporary chats; switch, search, reorder, fork, or delete persistent topics |
|
| Topics | Start, switch, search, fork, and delete browser topics |
|
||||||
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
| Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
|
||||||
| Workspace | Pick the project workspace before asking for file or shell work |
|
| Workspace | Pick the project workspace before asking for file or shell work |
|
||||||
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
| Access | Choose the access mode for local capabilities allowed by your gateway configuration |
|
||||||
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
| Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
|
||||||
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
|
||||||
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
| Apps | Install, test, update, and use local CLI App adapters and MCP presets |
|
||||||
| Skills | Inspect and manage installed skills, or discover skills from supported marketplaces |
|
| Skills | Inspect available built-in and workspace skills before relying on them |
|
||||||
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
| Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
|
||||||
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
|
||||||
|
|
||||||
@@ -90,10 +90,6 @@ workspace selection, and linked automations. Use a new topic when you want a
|
|||||||
separate context; use fork when you want to continue from an existing point
|
separate context; use fork when you want to continue from an existing point
|
||||||
without changing the original thread.
|
without changing the original thread.
|
||||||
|
|
||||||
Drag a topic within its current sidebar group to keep frequently used work in
|
|
||||||
your preferred order. Drag a topic from the sidebar into the composer when you
|
|
||||||
want to reference it in the next message instead of switching to it.
|
|
||||||
|
|
||||||
The message timeline shows both user-visible replies and agent activity. Long
|
The message timeline shows both user-visible replies and agent activity. Long
|
||||||
tool or reasoning sections can be expanded when you need the details.
|
tool or reasoning sections can be expanded when you need the details.
|
||||||
|
|
||||||
@@ -107,28 +103,6 @@ File previews follow the active session access mode. Restricted workspace access
|
|||||||
previews only files under the selected workspace. Full Access can preview files
|
previews only files under the selected workspace. Full Access can preview files
|
||||||
outside the workspace when that access mode is allowed by the gateway.
|
outside the workspace when that access mode is allowed by the gateway.
|
||||||
|
|
||||||
## Temporary Chats
|
|
||||||
|
|
||||||
Use a temporary chat for a conversation that should not be added to nanobot's
|
|
||||||
topic history or long-term memory:
|
|
||||||
|
|
||||||
1. Select **New topic**.
|
|
||||||
2. Select the **Temporary chat** control in the page header.
|
|
||||||
3. Send the first message.
|
|
||||||
|
|
||||||
You can keep more than one temporary chat open and switch between them under
|
|
||||||
**Temporary chats** in the sidebar while the current WebUI connection remains
|
|
||||||
open. Reloading or closing the page, restarting the gateway, or losing the
|
|
||||||
WebSocket connection ends all of them. They cannot be recovered afterward.
|
|
||||||
|
|
||||||
Temporary does not mean consequence-free. Requests still go to the configured
|
|
||||||
model provider, and tools can still change files, run commands, or affect
|
|
||||||
external services. Temporary chats always use the default workspace in
|
|
||||||
Restricted mode; the project picker and Full Access are unavailable. Commands
|
|
||||||
and tools that create durable goals, automations, or subagent work are also
|
|
||||||
unavailable. Use a regular topic when you need reusable context, scheduled work,
|
|
||||||
or a result you must retain.
|
|
||||||
|
|
||||||
## Workspace and Access
|
## Workspace and Access
|
||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
@@ -171,8 +145,7 @@ clients.
|
|||||||
The composer supports plain messages, image attachments, voice input when
|
The composer supports plain messages, image attachments, voice input when
|
||||||
transcription is configured, slash commands, and `@` mentions for installed Apps
|
transcription is configured, slash commands, and `@` mentions for installed Apps
|
||||||
or MCP presets. Select another topic from the `@` menu to attach a stable
|
or MCP presets. Select another topic from the `@` menu to attach a stable
|
||||||
reference, or drag that topic from the sidebar into the composer. Plain text
|
reference; plain text that happens to start with `@` does not attach history.
|
||||||
that happens to start with `@` does not attach history.
|
|
||||||
Restricted chats offer topics from the same project, while Full Access chats can
|
Restricted chats offer topics from the same project, while Full Access chats can
|
||||||
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
reference any WebUI topic. Nanobot reads a referenced topic only when its history
|
||||||
is relevant and can link it in the response. The model badge shows the current
|
is relevant and can link it in the response. The model badge shows the current
|
||||||
@@ -231,20 +204,10 @@ After an App or integration is available, mention it from the composer with
|
|||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Open **Skills → Installed** to review built-in and workspace-provided skills.
|
The Skills view shows the skill instructions available to the agent, including
|
||||||
You can search and filter them, inspect their instructions and setup
|
built-in skills and workspace-provided skills. Check this view when you want to
|
||||||
requirements, enable or disable them, and delete workspace skills you no longer
|
know whether nanobot already has a focused workflow for a task before you ask it
|
||||||
want.
|
to perform that task.
|
||||||
|
|
||||||
Open **Skills → Discover** to browse or search skills from skills.sh and
|
|
||||||
SkillHub. A marketplace skill is copied into the active agent workspace after
|
|
||||||
you confirm the installation. skills.sh installation requires Node.js with
|
|
||||||
`npx`; SkillHub installation does not.
|
|
||||||
|
|
||||||
Marketplace skills are third-party instructions and may include executable
|
|
||||||
scripts. Review the source and instructions before installing one, and enable
|
|
||||||
only skills you trust with the same files, tools, and credentials available to
|
|
||||||
your agent.
|
|
||||||
|
|
||||||
## Automations
|
## Automations
|
||||||
|
|
||||||
@@ -325,17 +288,10 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
|||||||
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
`http://<your-ip>:8765` from the other device and enter the secret in the login
|
||||||
form.
|
form.
|
||||||
|
|
||||||
Plain HTTP is enough for basic WebUI access, but browsers expose microphone
|
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||||
capture only in secure contexts. Voice input works on same-machine localhost;
|
install missing nanobot support packages, such as adding a channel dependency,
|
||||||
from another device, serve the WebUI over HTTPS with a certificate that device
|
are blocked by default. To let trusted remote administrators change the Python
|
||||||
trusts. Configure [`sslCertfile` and `sslKeyfile`](./websocket.md#tlsssl) on the
|
environment through the WebUI, opt in explicitly:
|
||||||
WebSocket channel and open `https://<your-host>:8765`, or terminate HTTPS at a
|
|
||||||
reverse proxy and use that proxy's HTTPS URL.
|
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps and installed
|
|
||||||
skills. Actions that install missing nanobot support packages or third-party
|
|
||||||
marketplace skills are blocked by default. To let trusted remote administrators
|
|
||||||
perform those installations through the WebUI, opt in explicitly:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -346,13 +302,12 @@ perform those installations through the WebUI, opt in explicitly:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use this only for a private deployment where every authenticated WebUI user is
|
Use this only for a private deployment where every authenticated WebUI user is
|
||||||
trusted to change nanobot's Python environment and install workspace skill
|
trusted to change the Python environment that nanobot runs in. If you publish
|
||||||
instructions or scripts. If you publish the WebUI through Nginx, Caddy,
|
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||||
Cloudflare Tunnel, or a similar service, treat it as remote access and leave
|
as remote access and leave package installs disabled unless that is intentional.
|
||||||
package and skill installs disabled unless that is intentional.
|
|
||||||
|
|
||||||
Optional feature installs use pip's configured package index, including
|
Optional feature installs use pip's configured package index, including
|
||||||
`PIP_INDEX_URL`. skills.sh marketplace installs use `npx` instead.
|
`PIP_INDEX_URL`.
|
||||||
|
|
||||||
Leave remote package installs disabled when the WebUI is exposed beyond a
|
Leave remote package installs disabled when the WebUI is exposed beyond a
|
||||||
private, trusted network.
|
private, trusted network.
|
||||||
@@ -367,10 +322,6 @@ If the page does not open, check these in order:
|
|||||||
4. You are opening port `8765`, not the gateway health port.
|
4. You are opening port `8765`, not the gateway health port.
|
||||||
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.
|
||||||
|
|
||||||
If voice input asks for a secure connection, use HTTPS with a certificate the
|
|
||||||
device trusts. Browsers do not expose microphone capture to
|
|
||||||
`http://<your-ip>` origins.
|
|
||||||
|
|
||||||
For detailed diagnostics, see
|
For detailed diagnostics, see
|
||||||
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
[`troubleshooting.md#webui-problems`](./troubleshooting.md#webui-problems).
|
||||||
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
For frontend development, see [`../webui/README.md`](../webui/README.md).
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.agent.memory import Consolidator
|
from nanobot.agent.memory import Consolidator
|
||||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class AutoCompact:
|
class AutoCompact:
|
||||||
_RECENT_SUFFIX_MESSAGES = MIN_COMPACTED_REPLAY_MESSAGES
|
_RECENT_SUFFIX_MESSAGES = 8
|
||||||
_INTERNAL_SESSION_PREFIXES = ("dream:",)
|
_INTERNAL_SESSION_PREFIXES = ("dream:",)
|
||||||
|
|
||||||
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
def __init__(self, sessions: SessionManager, consolidator: Consolidator,
|
||||||
@@ -45,9 +45,25 @@ class AutoCompact:
|
|||||||
return False
|
return False
|
||||||
return idle_seconds >= self._ttl * 60
|
return idle_seconds >= self._ttl * 60
|
||||||
|
|
||||||
def _has_unarchived_messages(self, key: str) -> bool:
|
def _has_compactable_idle_tail(self, key: str) -> bool:
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
return session.last_consolidated < len(session.messages)
|
tail = list(session.messages[session.last_consolidated:])
|
||||||
|
if not tail:
|
||||||
|
return False
|
||||||
|
probe = Session(
|
||||||
|
key=session.key,
|
||||||
|
messages=tail,
|
||||||
|
created_at=session.created_at,
|
||||||
|
updated_at=session.updated_at,
|
||||||
|
metadata={},
|
||||||
|
last_consolidated=0,
|
||||||
|
)
|
||||||
|
result = probe.retain_recent_legal_suffix(
|
||||||
|
self._RECENT_SUFFIX_MESSAGES,
|
||||||
|
extend_to_user=True,
|
||||||
|
)
|
||||||
|
messages_to_remove = result.dropped[result.already_consolidated_count:]
|
||||||
|
return bool(messages_to_remove)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_summary(text: str, last_active: datetime) -> str:
|
def _format_summary(text: str, last_active: datetime) -> str:
|
||||||
@@ -72,7 +88,7 @@ class AutoCompact:
|
|||||||
if key in active_session_keys:
|
if key in active_session_keys:
|
||||||
continue
|
continue
|
||||||
updated_at = info.get("updated_at")
|
updated_at = info.get("updated_at")
|
||||||
if self._is_expired(updated_at, now) and self._has_unarchived_messages(key):
|
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
try:
|
try:
|
||||||
runtime = resolve_runtime(session)
|
runtime = resolve_runtime(session)
|
||||||
|
|||||||
@@ -140,3 +140,10 @@ class AutomationTurnCoordinator:
|
|||||||
if pending_id:
|
if pending_id:
|
||||||
pending_ids.add(pending_id)
|
pending_ids.add(pending_id)
|
||||||
return pending_ids
|
return pending_ids
|
||||||
|
|
||||||
|
async def publish_next_deferred(self, session_key: str) -> bool:
|
||||||
|
return await publish_next_deferred_turn(
|
||||||
|
deferred_queues=self.deferred_queues,
|
||||||
|
publish_inbound=self._publish_inbound,
|
||||||
|
session_key=session_key,
|
||||||
|
)
|
||||||
|
|||||||
@@ -13,11 +13,7 @@ from nanobot.agent.tools import mcp as mcp_tools
|
|||||||
from nanobot.agent.tools import sessions as session_tools
|
from nanobot.agent.tools import sessions as session_tools
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.cli import utils as cli_app_utils
|
from nanobot.apps.cli import utils as cli_app_utils
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import InboundMessage
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
InboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.runtime_context import (
|
from nanobot.runtime_context import (
|
||||||
RUNTIME_CONTEXT_END,
|
RUNTIME_CONTEXT_END,
|
||||||
RUNTIME_CONTEXT_MESSAGE_META,
|
RUNTIME_CONTEXT_MESSAGE_META,
|
||||||
@@ -51,9 +47,6 @@ async def close_mcp(state: Any) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||||
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
|
|
||||||
await state.discard_session(msg.session_key)
|
|
||||||
return True
|
|
||||||
for handler in (
|
for handler in (
|
||||||
image_generation_tools.handle_runtime_control,
|
image_generation_tools.handle_runtime_control,
|
||||||
mcp_tools.handle_runtime_control,
|
mcp_tools.handle_runtime_control,
|
||||||
@@ -86,7 +79,6 @@ class ContextBuilder:
|
|||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory: bool = True,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
@@ -101,7 +93,6 @@ class ContextBuilder:
|
|||||||
|
|
||||||
parts.append(render_template("agent/tool_contract.md"))
|
parts.append(render_template("agent/tool_contract.md"))
|
||||||
|
|
||||||
if include_memory:
|
|
||||||
memory = self.memory.read_memory()
|
memory = self.memory.read_memory()
|
||||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||||
@@ -228,7 +219,6 @@ class ContextBuilder:
|
|||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||||
workspace: Path | None = None,
|
workspace: Path | None = None,
|
||||||
include_memory: bool = True,
|
|
||||||
include_memory_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
unified_session: bool = False,
|
unified_session: bool = False,
|
||||||
@@ -248,7 +238,6 @@ class ContextBuilder:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
workspace=root,
|
workspace=root,
|
||||||
include_memory=include_memory,
|
|
||||||
include_memory_recent_history=include_memory_recent_history,
|
include_memory_recent_history=include_memory_recent_history,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
unified_session=unified_session,
|
unified_session=unified_session,
|
||||||
|
|||||||
+13
-61
@@ -36,7 +36,6 @@ from nanobot.agent.tools.exec_session import ExecSessionManager
|
|||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.agent.turn_delivery import (
|
from nanobot.agent.turn_delivery import (
|
||||||
TurnDelivery,
|
TurnDelivery,
|
||||||
@@ -198,11 +197,6 @@ class AgentLoop:
|
|||||||
def tool_names(self) -> list[str]:
|
def tool_names(self) -> list[str]:
|
||||||
return self.tools.tool_names
|
return self.tools.tool_names
|
||||||
|
|
||||||
@property
|
|
||||||
def last_usage(self) -> Mapping[str, int]:
|
|
||||||
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
|
||||||
return self._last_usage
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provider(self) -> LLMProvider:
|
def provider(self) -> LLMProvider:
|
||||||
"""Provider selected for future turn admissions."""
|
"""Provider selected for future turn admissions."""
|
||||||
@@ -404,7 +398,6 @@ class AgentLoop:
|
|||||||
self._mcp_connecting = False
|
self._mcp_connecting = False
|
||||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||||
self._discarding_sessions: set[str] = set()
|
|
||||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||||
self._close_mcp_lock = asyncio.Lock()
|
self._close_mcp_lock = asyncio.Lock()
|
||||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||||
@@ -454,6 +447,7 @@ class AgentLoop:
|
|||||||
if model_preset:
|
if model_preset:
|
||||||
self.set_model_preset(model_preset, publish_update=False)
|
self.set_model_preset(model_preset, publish_update=False)
|
||||||
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
|
||||||
|
self._runtime_vars: dict[str, Any] = {}
|
||||||
self._current_iteration: int = 0
|
self._current_iteration: int = 0
|
||||||
self.commands = CommandRouter()
|
self.commands = CommandRouter()
|
||||||
register_builtin_commands(self.commands)
|
register_builtin_commands(self.commands)
|
||||||
@@ -628,13 +622,10 @@ class AgentLoop:
|
|||||||
loader = ToolLoader()
|
loader = ToolLoader()
|
||||||
registered = loader.load(ctx, self.tools)
|
registered = loader.load(ctx, self.tools)
|
||||||
|
|
||||||
# MyTool receives only the explicit runtime-control capability.
|
# MyTool needs runtime state reference — manual registration
|
||||||
if self.tools_config.my.enable:
|
if self.tools_config.my.enable:
|
||||||
self.tools.register(
|
self.tools.register(
|
||||||
MyTool(
|
MyTool(runtime_state=self, modify_allowed=self.tools_config.my.allow_set)
|
||||||
runtime_control=AgentRuntimeControl(self),
|
|
||||||
modify_allowed=self.tools_config.my.allow_set,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
registered.append("my")
|
registered.append("my")
|
||||||
|
|
||||||
@@ -730,7 +721,6 @@ class AgentLoop:
|
|||||||
session_summary=ctx.pending_summary,
|
session_summary=ctx.pending_summary,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
include_memory=ctx.session.policy.persist,
|
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
session_key=ctx.session.key,
|
session_key=ctx.session.key,
|
||||||
unified_session=self._unified_session,
|
unified_session=self._unified_session,
|
||||||
@@ -796,9 +786,9 @@ class AgentLoop:
|
|||||||
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
logger.warning("Command '{}' matched but dispatch returned None", raw)
|
||||||
|
|
||||||
async def _cancel_active_tasks(self, key: str) -> int:
|
async def _cancel_active_tasks(self, key: str) -> int:
|
||||||
"""Cancel and await all active work for *key*.
|
"""Cancel and await all active tasks and subagents for *key*.
|
||||||
|
|
||||||
Returns the total number of cancelled tasks, subagents, and exec sessions.
|
Returns the total number of cancelled tasks + subagents.
|
||||||
"""
|
"""
|
||||||
tasks = tuple(self._active_tasks.pop(key, set()))
|
tasks = tuple(self._active_tasks.pop(key, set()))
|
||||||
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
|
||||||
@@ -806,17 +796,7 @@ class AgentLoop:
|
|||||||
with suppress(asyncio.CancelledError, Exception):
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
await t
|
await t
|
||||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||||
exec_cancelled = await self._exec_session_manager.terminate_by_owner(key)
|
return cancelled + sub_cancelled
|
||||||
return cancelled + sub_cancelled + exec_cancelled
|
|
||||||
|
|
||||||
async def discard_session(self, key: str) -> None:
|
|
||||||
"""Stop active work for *key* and forget its cached session."""
|
|
||||||
self._discarding_sessions.add(key)
|
|
||||||
try:
|
|
||||||
self.sessions.invalidate(key)
|
|
||||||
await self._cancel_active_tasks(key)
|
|
||||||
finally:
|
|
||||||
self._discarding_sessions.discard(key)
|
|
||||||
|
|
||||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||||
"""Return the session key used for task routing and mid-turn injections."""
|
"""Return the session key used for task routing and mid-turn injections."""
|
||||||
@@ -1181,11 +1161,6 @@ class AgentLoop:
|
|||||||
effective_key = self._effective_session_key(msg)
|
effective_key = self._effective_session_key(msg)
|
||||||
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
if await agent_context.handle_runtime_control(self, msg, self.tools):
|
||||||
continue
|
continue
|
||||||
if (
|
|
||||||
msg.require_existing_session
|
|
||||||
and self.sessions.get_cached(effective_key) is None
|
|
||||||
):
|
|
||||||
continue
|
|
||||||
if self.commands.is_priority(raw):
|
if self.commands.is_priority(raw):
|
||||||
await self._dispatch_command_inline(
|
await self._dispatch_command_inline(
|
||||||
msg, effective_key, raw,
|
msg, effective_key, raw,
|
||||||
@@ -1304,8 +1279,6 @@ class AgentLoop:
|
|||||||
# _emit_checkpoint during tool execution; materializing
|
# _emit_checkpoint during tool execution; materializing
|
||||||
# it into session history now makes it visible in the
|
# it into session history now makes it visible in the
|
||||||
# next conversation turn.
|
# next conversation turn.
|
||||||
if session_key in self._discarding_sessions:
|
|
||||||
raise
|
|
||||||
try:
|
try:
|
||||||
key = self._effective_session_key(msg)
|
key = self._effective_session_key(msg)
|
||||||
session = self.sessions.get_or_create(key)
|
session = self.sessions.get_or_create(key)
|
||||||
@@ -1583,7 +1556,6 @@ class AgentLoop:
|
|||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
streamed_content: bool,
|
streamed_content: bool,
|
||||||
*,
|
*,
|
||||||
log_content: bool = True,
|
|
||||||
turn_latency_ms: int | None = None,
|
turn_latency_ms: int | None = None,
|
||||||
) -> OutboundMessage | None:
|
) -> OutboundMessage | None:
|
||||||
"""Assemble the final outbound message from turn results."""
|
"""Assemble the final outbound message from turn results."""
|
||||||
@@ -1592,11 +1564,8 @@ class AgentLoop:
|
|||||||
if not had_injections or stop_reason == "empty_final_response":
|
if not had_injections or stop_reason == "empty_final_response":
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if log_content:
|
|
||||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
else:
|
|
||||||
logger.info("Response to {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
|
||||||
|
|
||||||
event = None
|
event = None
|
||||||
meta = dict(msg.metadata or {})
|
meta = dict(msg.metadata or {})
|
||||||
@@ -1625,33 +1594,17 @@ class AgentLoop:
|
|||||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
if ctx.session is None:
|
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||||
if msg.require_existing_session:
|
|
||||||
ctx.session = self.sessions.get_cached(ctx.session_key)
|
|
||||||
if ctx.session is None:
|
|
||||||
raise RuntimeError("required session is not active")
|
|
||||||
else:
|
|
||||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
|
||||||
session = ctx.session
|
|
||||||
ctx.ephemeral = ctx.ephemeral or not session.policy.persist
|
|
||||||
tools = ctx.tools or self.tools
|
|
||||||
if session.policy.disabled_tools:
|
|
||||||
restricted = ToolRegistry()
|
|
||||||
for name in tools.tool_names:
|
|
||||||
tool = tools.get(name)
|
|
||||||
if name not in session.policy.disabled_tools and tool:
|
|
||||||
restricted.register(tool)
|
|
||||||
tools = restricted
|
|
||||||
ctx.tools = tools
|
|
||||||
|
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
logger.info("Processing system message from {}", msg.sender_id)
|
logger.info("Processing system message from {}", msg.sender_id)
|
||||||
elif session.policy.log_content:
|
|
||||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
|
||||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
|
||||||
else:
|
else:
|
||||||
logger.info("Processing message from {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||||
|
|
||||||
|
# Session is already fetched by the caller (_process_message) but
|
||||||
|
# ensure it exists in case this handler is invoked independently.
|
||||||
|
if ctx.session is None:
|
||||||
|
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||||
|
session = ctx.session
|
||||||
self._remember_unified_session_route(
|
self._remember_unified_session_route(
|
||||||
session,
|
session,
|
||||||
msg,
|
msg,
|
||||||
@@ -1954,7 +1907,6 @@ class AgentLoop:
|
|||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.streamed_content,
|
ctx.streamed_content,
|
||||||
log_content=ctx.require_session().policy.log_content,
|
|
||||||
turn_latency_ms=ctx.turn_latency_ms,
|
turn_latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
if ctx.ephemeral and ctx.outbound is not None:
|
if ctx.ephemeral and ctx.outbound is not None:
|
||||||
|
|||||||
+36
-36
@@ -21,7 +21,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.runtime_context import public_history_messages
|
from nanobot.runtime_context import public_history_messages
|
||||||
from nanobot.session.manager import MIN_COMPACTED_REPLAY_MESSAGES, Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.gitstore import GitStore
|
from nanobot.utils.gitstore import GitStore
|
||||||
from nanobot.utils.helpers import (
|
from nanobot.utils.helpers import (
|
||||||
content_with_media_breadcrumbs,
|
content_with_media_breadcrumbs,
|
||||||
@@ -858,13 +858,14 @@ class Consolidator:
|
|||||||
return last_boundary
|
return last_boundary
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _full_replay_history(
|
def _full_unconsolidated_history(
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return all messages that can reach the next model prompt."""
|
"""Return the whole unconsolidated tail for consolidation decisions."""
|
||||||
if not session.messages:
|
unconsolidated_count = len(session.messages) - session.last_consolidated
|
||||||
|
if unconsolidated_count <= 0:
|
||||||
return []
|
return []
|
||||||
return session.get_history(max_messages=len(session.messages))
|
return session.get_history(max_messages=unconsolidated_count)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _replay_overflow_boundary(
|
def _replay_overflow_boundary(
|
||||||
@@ -947,8 +948,8 @@ class Consolidator:
|
|||||||
*,
|
*,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt size from the full replayable session history."""
|
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||||
history = self._full_replay_history(session)
|
history = self._full_unconsolidated_history(session)
|
||||||
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
||||||
# Include archived summary in estimation so the budget accounts for it.
|
# Include archived summary in estimation so the budget accounts for it.
|
||||||
meta = session.metadata.get("_last_summary")
|
meta = session.metadata.get("_last_summary")
|
||||||
@@ -1159,37 +1160,42 @@ class Consolidator:
|
|||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
*,
|
||||||
runtime: LLMRuntime,
|
runtime: LLMRuntime,
|
||||||
max_suffix: int = MIN_COMPACTED_REPLAY_MESSAGES,
|
max_suffix: int = 8,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Archive the full idle tail while keeping recent messages replayable.
|
"""Archive an idle prefix and hide it from replay without deleting it."""
|
||||||
|
|
||||||
``max_suffix`` remains accepted for SDK compatibility. Replay retention
|
|
||||||
is now derived independently from archive progress using the project-wide
|
|
||||||
compacted-session window.
|
|
||||||
"""
|
|
||||||
if max_suffix != MIN_COMPACTED_REPLAY_MESSAGES:
|
|
||||||
logger.debug(
|
|
||||||
"Idle-session compact for {} uses the fixed replay window ({}, requested {})",
|
|
||||||
session_key,
|
|
||||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
|
||||||
max_suffix,
|
|
||||||
)
|
|
||||||
lock = self.get_lock(session_key)
|
lock = self.get_lock(session_key)
|
||||||
async with lock:
|
async with lock:
|
||||||
self.sessions.invalidate(session_key)
|
self.sessions.invalidate(session_key)
|
||||||
session = self.sessions.get_or_create(session_key)
|
session = self.sessions.get_or_create(session_key)
|
||||||
|
|
||||||
archive_start = session.last_consolidated
|
messages_to_summarize = list(session.messages[session.last_consolidated:])
|
||||||
messages_to_archive = list(session.messages[archive_start:])
|
if not messages_to_summarize:
|
||||||
if not messages_to_archive:
|
self.sessions.save(session)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
probe = Session(
|
||||||
|
key=session.key,
|
||||||
|
messages=messages_to_summarize.copy(),
|
||||||
|
created_at=session.created_at,
|
||||||
|
updated_at=session.updated_at,
|
||||||
|
metadata={},
|
||||||
|
last_consolidated=0,
|
||||||
|
)
|
||||||
|
result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
|
||||||
|
visible_suffix = probe.messages
|
||||||
|
messages_to_remove = result.dropped
|
||||||
|
|
||||||
|
if not messages_to_remove:
|
||||||
|
self.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
archive_end = archive_start + len(messages_to_archive)
|
# The visible suffix informs the summary but stays out of raw fallback.
|
||||||
summary = await self.archive(
|
summary = await self.archive(
|
||||||
messages_to_archive,
|
messages_to_remove,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
|
summary_messages=messages_to_summarize,
|
||||||
)
|
)
|
||||||
|
|
||||||
if summary and summary != "(nothing)":
|
if summary and summary != "(nothing)":
|
||||||
@@ -1198,22 +1204,16 @@ class Consolidator:
|
|||||||
"last_active": last_active.isoformat(),
|
"last_active": last_active.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# A turn can append while the provider call is in flight. Advance only
|
# Preserve history and advance only the replay boundary.
|
||||||
# through the captured batch so new messages remain eligible next time.
|
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||||
session.last_consolidated = archive_end
|
|
||||||
session.provider_state = None
|
session.provider_state = None
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|
||||||
visible = session.get_history(
|
|
||||||
max_messages=MIN_COMPACTED_REPLAY_MESSAGES,
|
|
||||||
extend_to_user=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
|
"Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
|
||||||
session_key,
|
session_key,
|
||||||
len(messages_to_archive),
|
len(messages_to_remove),
|
||||||
len(visible),
|
len(visible_suffix),
|
||||||
len(session.messages),
|
len(session.messages),
|
||||||
bool(summary),
|
bool(summary),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import json
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, TypedDict
|
from typing import Any, Callable, TypedDict
|
||||||
@@ -158,10 +157,6 @@ class SubagentManager:
|
|||||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||||
|
|
||||||
def runtime_statuses(self) -> Mapping[str, SubagentStatus]:
|
|
||||||
"""Return the observable task statuses used by runtime-control snapshots."""
|
|
||||||
return self._task_statuses
|
|
||||||
|
|
||||||
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
def set_provider(self, provider: LLMProvider, model: str) -> None:
|
||||||
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
"""Update the deprecated runtime source used by legacy ``spawn`` calls."""
|
||||||
warnings.warn(
|
warnings.warn(
|
||||||
|
|||||||
@@ -785,6 +785,22 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li
|
|||||||
return best_ratio, best_start, best_window_lines, hints
|
return best_ratio, best_start, best_window_lines, hints
|
||||||
|
|
||||||
|
|
||||||
|
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
||||||
|
"""Locate old_text in content with a multi-level fallback chain:
|
||||||
|
|
||||||
|
1. Exact substring match
|
||||||
|
2. Line-trimmed sliding window (handles indentation differences)
|
||||||
|
3. Smart quote normalization (curly ↔ straight quotes)
|
||||||
|
|
||||||
|
Both inputs should use LF line endings (caller normalises CRLF).
|
||||||
|
Returns (matched_fragment, count) or (None, 0).
|
||||||
|
"""
|
||||||
|
matches = _find_matches(content, old_text)
|
||||||
|
if not matches:
|
||||||
|
return None, 0
|
||||||
|
return matches[0].text, len(matches)
|
||||||
|
|
||||||
|
|
||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to edit"),
|
path=StringSchema("The file path to edit"),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
_SKIP_MODULES = frozenset({
|
_SKIP_MODULES = frozenset({
|
||||||
"base", "schema", "registry", "context", "loader", "config",
|
"base", "schema", "registry", "context", "loader", "config",
|
||||||
"file_state", "sandbox", "mcp", "__init__", "runtime_control",
|
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-17
@@ -975,8 +975,11 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
name: str, cfg: "MCPServerConfig"
|
||||||
) -> bool:
|
) -> tuple[str, AsyncExitStack | None]:
|
||||||
|
server_stack = AsyncExitStack()
|
||||||
|
await server_stack.__aenter__()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -988,7 +991,8 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -999,7 +1003,8 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1017,7 +1022,8 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1044,7 +1050,8 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(
|
||||||
@@ -1060,7 +1067,8 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
return False
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1163,7 +1171,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return True
|
return name, server_stack
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1183,7 +1191,9 @@ async def connect_mcp_servers(
|
|||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
return False
|
with suppress(Exception):
|
||||||
|
await server_stack.aclose()
|
||||||
|
return name, None
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1193,30 +1203,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
|
stack: AsyncExitStack | None = None
|
||||||
try:
|
try:
|
||||||
async with AsyncExitStack() as stack:
|
_, stack = await open_single_server(name, cfg)
|
||||||
connected = await open_single_server(name, cfg, stack)
|
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_result(connected)
|
ready.set_result(stack is not None)
|
||||||
if connected:
|
if stack is not None:
|
||||||
await close_requested.wait()
|
await close_requested.wait()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
if stack is not None:
|
||||||
|
await stack.aclose()
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException as exc:
|
except BaseException:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
await asyncio.shield(owner)
|
||||||
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
|
|
||||||
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
|
|
||||||
return name, None
|
|
||||||
raise
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
|||||||
@@ -3,7 +3,15 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from nanobot.config.paths import get_media_dir
|
from nanobot.config.paths import get_media_dir
|
||||||
from nanobot.security.workspace_policy import resolve_allowed_path
|
from nanobot.security.workspace_policy import (
|
||||||
|
is_path_within,
|
||||||
|
resolve_allowed_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_under(path: Path, directory: Path) -> bool:
|
||||||
|
"""Return True when path resolves under directory."""
|
||||||
|
return is_path_within(path, directory)
|
||||||
|
|
||||||
|
|
||||||
def resolve_workspace_path(
|
def resolve_workspace_path(
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
"""Explicit runtime state boundary used by :class:`MyTool`."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Protocol, TypeAlias, runtime_checkable
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
|
||||||
from nanobot.agent.tools.shell import ExecToolConfig
|
|
||||||
from nanobot.agent.tools.web import WebToolsConfig
|
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
|
||||||
|
|
||||||
|
|
||||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
|
||||||
JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]
|
|
||||||
|
|
||||||
|
|
||||||
RUNTIME_SNAPSHOT_KEYS = frozenset({
|
|
||||||
"model",
|
|
||||||
"model_preset",
|
|
||||||
"model_presets",
|
|
||||||
"max_iterations",
|
|
||||||
"context_window_tokens",
|
|
||||||
"workspace",
|
|
||||||
"provider_retry_mode",
|
|
||||||
"max_tool_result_chars",
|
|
||||||
"current_iteration",
|
|
||||||
"_current_iteration",
|
|
||||||
"tool_names",
|
|
||||||
"web_config",
|
|
||||||
"exec_config",
|
|
||||||
"subagents",
|
|
||||||
"_last_usage",
|
|
||||||
})
|
|
||||||
|
|
||||||
RUNTIME_COMMAND_KEYS = frozenset({
|
|
||||||
"model",
|
|
||||||
"model_preset",
|
|
||||||
"max_iterations",
|
|
||||||
"context_window_tokens",
|
|
||||||
"provider_retry_mode",
|
|
||||||
"max_tool_result_chars",
|
|
||||||
"workspace",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RuntimeSnapshot:
|
|
||||||
"""Detached, allowlisted values available to self-inspection."""
|
|
||||||
|
|
||||||
model: str
|
|
||||||
model_preset: str | None
|
|
||||||
model_presets: dict[str, dict[str, object]]
|
|
||||||
max_iterations: int
|
|
||||||
context_window_tokens: int
|
|
||||||
workspace: Path | str
|
|
||||||
provider_retry_mode: str
|
|
||||||
max_tool_result_chars: int
|
|
||||||
current_iteration: int
|
|
||||||
tool_names: list[str]
|
|
||||||
web_config: dict[str, object]
|
|
||||||
exec_config: dict[str, object]
|
|
||||||
subagent_statuses: dict[str, dict[str, object]]
|
|
||||||
last_usage: dict[str, int]
|
|
||||||
scratchpad: dict[str, JsonValue]
|
|
||||||
|
|
||||||
def as_mapping(self) -> Mapping[str, object]:
|
|
||||||
"""Return the fixed public names understood by ``MyTool``."""
|
|
||||||
values: dict[str, object] = {
|
|
||||||
"model": self.model,
|
|
||||||
"model_preset": self.model_preset,
|
|
||||||
"model_presets": self.model_presets,
|
|
||||||
"max_iterations": self.max_iterations,
|
|
||||||
"context_window_tokens": self.context_window_tokens,
|
|
||||||
"workspace": self.workspace,
|
|
||||||
"provider_retry_mode": self.provider_retry_mode,
|
|
||||||
"max_tool_result_chars": self.max_tool_result_chars,
|
|
||||||
"current_iteration": self.current_iteration,
|
|
||||||
"_current_iteration": self.current_iteration,
|
|
||||||
"tool_names": self.tool_names,
|
|
||||||
"web_config": self.web_config,
|
|
||||||
"exec_config": self.exec_config,
|
|
||||||
"subagents": {"_task_statuses": self.subagent_statuses},
|
|
||||||
"_last_usage": self.last_usage,
|
|
||||||
}
|
|
||||||
assert values.keys() == RUNTIME_SNAPSHOT_KEYS
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
|
||||||
class RuntimeControl(Protocol):
|
|
||||||
"""The complete runtime capability exposed to ``MyTool``."""
|
|
||||||
|
|
||||||
def snapshot(self) -> RuntimeSnapshot: ...
|
|
||||||
|
|
||||||
def set_model(self, model: str) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_model_preset(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
session_key: str | None,
|
|
||||||
) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_max_iterations(self, value: int) -> None: ...
|
|
||||||
|
|
||||||
def set_context_window_tokens(self, value: int) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_provider_retry_mode(self, value: str) -> None: ...
|
|
||||||
|
|
||||||
def set_max_tool_result_chars(self, value: int) -> None: ...
|
|
||||||
|
|
||||||
def set_workspace_display(self, value: str) -> None: ...
|
|
||||||
|
|
||||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None: ...
|
|
||||||
|
|
||||||
|
|
||||||
class _RuntimeControlTarget(Protocol):
|
|
||||||
"""Narrow structural dependency required by ``AgentRuntimeControl``."""
|
|
||||||
|
|
||||||
max_iterations: int
|
|
||||||
provider_retry_mode: str
|
|
||||||
max_tool_result_chars: int
|
|
||||||
web_config: WebToolsConfig
|
|
||||||
exec_config: ExecToolConfig
|
|
||||||
subagents: SubagentManager
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model(self) -> str: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_preset(self) -> str | None: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def model_presets(self) -> Mapping[str, ModelPresetConfig]: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def context_window_tokens(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def workspace(self) -> Path: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def current_iteration(self) -> int: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tool_names(self) -> list[str]: ...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def last_usage(self) -> Mapping[str, int]: ...
|
|
||||||
|
|
||||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_model_preset(self, name: str | None) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
def set_session_model_preset(self, session_key: str, name: str) -> LLMRuntime: ...
|
|
||||||
|
|
||||||
|
|
||||||
class AgentRuntimeControl:
|
|
||||||
"""Allowlisted adapter from agent-loop state to ``RuntimeControl``."""
|
|
||||||
|
|
||||||
def __init__(self, target: _RuntimeControlTarget) -> None:
|
|
||||||
self.__target = target
|
|
||||||
self.__scratchpad: dict[str, JsonValue] = {}
|
|
||||||
self.__workspace_display: str | None = None
|
|
||||||
|
|
||||||
def snapshot(self) -> RuntimeSnapshot:
|
|
||||||
target = self.__target
|
|
||||||
return RuntimeSnapshot(
|
|
||||||
model=target.model,
|
|
||||||
model_preset=target.model_preset,
|
|
||||||
model_presets=_snapshot_model_presets(target.model_presets),
|
|
||||||
max_iterations=target.max_iterations,
|
|
||||||
context_window_tokens=target.context_window_tokens,
|
|
||||||
workspace=(
|
|
||||||
self.__workspace_display
|
|
||||||
if self.__workspace_display is not None
|
|
||||||
else target.workspace
|
|
||||||
),
|
|
||||||
provider_retry_mode=target.provider_retry_mode,
|
|
||||||
max_tool_result_chars=target.max_tool_result_chars,
|
|
||||||
current_iteration=target.current_iteration,
|
|
||||||
tool_names=list(target.tool_names),
|
|
||||||
web_config=_snapshot_web_config(target.web_config),
|
|
||||||
exec_config=_snapshot_exec_config(target.exec_config),
|
|
||||||
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
|
||||||
last_usage=dict(target.last_usage),
|
|
||||||
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_model(self, model: str) -> LLMRuntime:
|
|
||||||
return self.__target.set_runtime_model(model)
|
|
||||||
|
|
||||||
def set_model_preset(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
*,
|
|
||||||
session_key: str | None,
|
|
||||||
) -> LLMRuntime:
|
|
||||||
if session_key is not None:
|
|
||||||
return self.__target.set_session_model_preset(session_key, name)
|
|
||||||
return self.__target.set_model_preset(name)
|
|
||||||
|
|
||||||
def set_max_iterations(self, value: int) -> None:
|
|
||||||
self.__target.max_iterations = value
|
|
||||||
self.__target.subagents.max_iterations = value
|
|
||||||
|
|
||||||
def set_context_window_tokens(self, value: int) -> LLMRuntime:
|
|
||||||
return self.__target.set_runtime_context_window(value)
|
|
||||||
|
|
||||||
def set_provider_retry_mode(self, value: str) -> None:
|
|
||||||
self.__target.provider_retry_mode = value
|
|
||||||
|
|
||||||
def set_max_tool_result_chars(self, value: int) -> None:
|
|
||||||
self.__target.max_tool_result_chars = value
|
|
||||||
|
|
||||||
def set_workspace_display(self, value: str) -> None:
|
|
||||||
"""Preserve MyTool display compatibility without changing path enforcement."""
|
|
||||||
self.__workspace_display = value
|
|
||||||
|
|
||||||
def set_scratchpad(self, key: str, value: JsonValue, *, max_keys: int) -> None:
|
|
||||||
if key not in self.__scratchpad and len(self.__scratchpad) >= max_keys:
|
|
||||||
raise ValueError(f"scratchpad is full (max {max_keys} keys)")
|
|
||||||
self.__scratchpad[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_model_presets(
|
|
||||||
presets: Mapping[str, ModelPresetConfig],
|
|
||||||
) -> dict[str, dict[str, object]]:
|
|
||||||
return {
|
|
||||||
name: {
|
|
||||||
"label": preset.label,
|
|
||||||
"model": preset.model,
|
|
||||||
"provider": preset.provider,
|
|
||||||
"max_tokens": preset.max_tokens,
|
|
||||||
"context_window_tokens": preset.context_window_tokens,
|
|
||||||
"temperature": preset.temperature,
|
|
||||||
"reasoning_effort": preset.reasoning_effort,
|
|
||||||
}
|
|
||||||
for name, preset in presets.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_web_config(config: WebToolsConfig) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"enable": config.enable,
|
|
||||||
# Proxy URLs may embed credentials. Presence is enough for diagnosis.
|
|
||||||
"proxy": "<configured>" if config.proxy else config.proxy,
|
|
||||||
"user_agent": config.user_agent,
|
|
||||||
"search": {
|
|
||||||
"provider": config.search.provider,
|
|
||||||
"base_url": config.search.base_url,
|
|
||||||
"max_results": config.search.max_results,
|
|
||||||
"timeout": config.search.timeout,
|
|
||||||
},
|
|
||||||
"fetch": {
|
|
||||||
"use_jina_reader": config.fetch.use_jina_reader,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_exec_config(config: ExecToolConfig) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"enable": config.enable,
|
|
||||||
"timeout": config.timeout,
|
|
||||||
"path_prepend": config.path_prepend,
|
|
||||||
"path_append": config.path_append,
|
|
||||||
"sandbox": config.sandbox,
|
|
||||||
"sandbox_ro_binds": list(config.sandbox_ro_binds),
|
|
||||||
"sandbox_rw_binds": list(config.sandbox_rw_binds),
|
|
||||||
"allowed_env_keys": list(config.allowed_env_keys),
|
|
||||||
"allow_patterns": list(config.allow_patterns),
|
|
||||||
"deny_patterns": list(config.deny_patterns),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_subagent_statuses(
|
|
||||||
manager: SubagentManager,
|
|
||||||
) -> dict[str, dict[str, object]]:
|
|
||||||
return {
|
|
||||||
task_id: _snapshot_subagent_status(status)
|
|
||||||
for task_id, status in manager.runtime_statuses().items()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"task_id": status.task_id,
|
|
||||||
"label": status.label,
|
|
||||||
"task_description": status.task_description,
|
|
||||||
"started_at": status.started_at,
|
|
||||||
"phase": status.phase,
|
|
||||||
"iteration": status.iteration,
|
|
||||||
"tool_events": [dict(event) for event in status.tool_events],
|
|
||||||
"usage": dict(status.usage),
|
|
||||||
"stop_reason": status.stop_reason,
|
|
||||||
"error": status.error,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_json_mapping(values: Mapping[str, JsonValue]) -> dict[str, JsonValue]:
|
|
||||||
return {key: _snapshot_json_value(value) for key, value in values.items()}
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot_json_value(value: JsonValue) -> JsonValue:
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_snapshot_json_value(item) for item in value]
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return {
|
|
||||||
key: _snapshot_json_value(item)
|
|
||||||
for key, item in value.items()
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""RuntimeState protocol: agent loop state exposed to MyTool."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Protocol
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nanobot.agent.subagent import SubagentManager
|
||||||
|
from nanobot.agent.tools.shell import ExecToolConfig
|
||||||
|
from nanobot.agent.tools.web import WebToolsConfig
|
||||||
|
from nanobot.utils.llm_runtime import LLMRuntime
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeState(Protocol):
|
||||||
|
"""Minimum contract that MyTool requires from its runtime state provider.
|
||||||
|
|
||||||
|
In practice, this is always satisfied by ``AgentLoop``. MyTool also
|
||||||
|
accesses arbitrary attributes dynamically (via ``getattr`` / ``setattr``)
|
||||||
|
for dot-path inspection and modification; those paths are validated at
|
||||||
|
runtime rather than by this protocol.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_iterations(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_iteration(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tool_names(self) -> list[str]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def workspace(self) -> Path: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def provider_retry_mode(self) -> str: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_tool_result_chars(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def context_window_tokens(self) -> int: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def web_config(self) -> WebToolsConfig: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exec_config(self) -> ExecToolConfig: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def subagents(self) -> SubagentManager: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _runtime_vars(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _last_usage(self) -> dict[str, int]: ...
|
||||||
|
|
||||||
|
def _sync_subagent_runtime_limits(self) -> None: ...
|
||||||
|
|
||||||
|
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
def set_session_model_preset(
|
||||||
|
self,
|
||||||
|
session_key: str,
|
||||||
|
name: str,
|
||||||
|
) -> LLMRuntime: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_preset(self) -> str | None: ...
|
||||||
+165
-196
@@ -1,7 +1,8 @@
|
|||||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||||
|
|
||||||
# Tool.execute accepts heterogeneous schemas.
|
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
|
||||||
# pyright: reportIncompatibleMethodOverride=false
|
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
|
||||||
|
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -13,13 +14,7 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.agent.tools.base import Tool, ToolResult
|
from nanobot.agent.tools.base import Tool, ToolResult
|
||||||
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
from nanobot.agent.tools.context import current_request_context, current_request_session_key
|
||||||
from nanobot.agent.tools.runtime_control import (
|
from nanobot.agent.tools.runtime_state import RuntimeState
|
||||||
RUNTIME_COMMAND_KEYS,
|
|
||||||
RUNTIME_SNAPSHOT_KEYS,
|
|
||||||
JsonValue,
|
|
||||||
RuntimeControl,
|
|
||||||
RuntimeSnapshot,
|
|
||||||
)
|
|
||||||
from nanobot.config_base import Base
|
from nanobot.config_base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -33,28 +28,25 @@ class MyToolConfig(Base):
|
|||||||
allow_set: bool = False
|
allow_set: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_real_attr(obj: Any, key: str) -> bool:
|
||||||
|
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return key in obj
|
||||||
|
d = getattr(obj, "__dict__", None)
|
||||||
|
if d is not None and key in d:
|
||||||
|
return True
|
||||||
|
for cls in type(obj).__mro__:
|
||||||
|
if key in cls.__dict__:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
|
||||||
from nanobot.agent.subagent import SubagentStatus
|
from nanobot.agent.subagent import SubagentStatus
|
||||||
|
|
||||||
return isinstance(value, SubagentStatus)
|
return isinstance(value, SubagentStatus)
|
||||||
|
|
||||||
|
|
||||||
def _is_subagent_status_snapshot(value: object) -> TypeGuard[Mapping[str, object]]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
return False
|
|
||||||
return all(
|
|
||||||
field in value
|
|
||||||
for field in ("task_id", "label", "task_description", "started_at", "phase")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_string_mapping(value: object) -> TypeGuard[Mapping[str, object]]:
|
|
||||||
if not isinstance(value, Mapping):
|
|
||||||
return False
|
|
||||||
mapping = cast(Mapping[object, object], value)
|
|
||||||
return all(isinstance(key, str) for key in mapping)
|
|
||||||
|
|
||||||
|
|
||||||
class MyTool(Tool):
|
class MyTool(Tool):
|
||||||
"""Check and set the agent loop's runtime configuration."""
|
"""Check and set the agent loop's runtime configuration."""
|
||||||
|
|
||||||
@@ -87,10 +79,7 @@ class MyTool(Tool):
|
|||||||
|
|
||||||
READ_ONLY = frozenset({
|
READ_ONLY = frozenset({
|
||||||
"subagents", # observable but replacing it would break the system
|
"subagents", # observable but replacing it would break the system
|
||||||
"tool_names",
|
|
||||||
"current_iteration",
|
|
||||||
"_current_iteration", # updated by runner only
|
"_current_iteration", # updated by runner only
|
||||||
"_last_usage",
|
|
||||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||||
"model_presets", # config-derived catalog; changes require config reload
|
"model_presets", # config-derived catalog; changes require config reload
|
||||||
@@ -114,6 +103,13 @@ class MyTool(Tool):
|
|||||||
"private_key", "access_token", "refresh_token", "auth",
|
"private_key", "access_token", "refresh_token", "auth",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _is_sensitive_field_name(cls, name: str) -> bool:
|
||||||
|
lowered = name.lower()
|
||||||
|
return lowered in cls._SENSITIVE_NAMES or any(
|
||||||
|
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
||||||
|
)
|
||||||
|
|
||||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||||
@@ -127,15 +123,15 @@ class MyTool(Tool):
|
|||||||
"context_window_tokens",
|
"context_window_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, runtime_control: RuntimeControl, modify_allowed: bool = True) -> None:
|
def __init__(self, runtime_state: RuntimeState, modify_allowed: bool = True) -> None:
|
||||||
self._runtime_control = runtime_control
|
self._runtime_state = runtime_state
|
||||||
self._modify_allowed = modify_allowed
|
self._modify_allowed = modify_allowed
|
||||||
|
|
||||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||||
cls = self.__class__
|
cls = self.__class__
|
||||||
result = cls.__new__(cls)
|
result = cls.__new__(cls)
|
||||||
memo[id(self)] = result
|
memo[id(self)] = result
|
||||||
result._runtime_control = self._runtime_control
|
result._runtime_state = self._runtime_state
|
||||||
result._modify_allowed = self._modify_allowed
|
result._modify_allowed = self._modify_allowed
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -212,12 +208,9 @@ class MyTool(Tool):
|
|||||||
# Path resolution
|
# Path resolution
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _resolve_path(
|
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||||
self,
|
|
||||||
snapshot: RuntimeSnapshot,
|
|
||||||
path: str,
|
|
||||||
) -> tuple[object | None, str | None]:
|
|
||||||
parts = path.split(".")
|
parts = path.split(".")
|
||||||
|
obj: Any = self._runtime_state
|
||||||
for part in parts:
|
for part in parts:
|
||||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
@@ -225,13 +218,17 @@ class MyTool(Tool):
|
|||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
if part.lower() in self._SENSITIVE_NAMES:
|
if part.lower() in self._SENSITIVE_NAMES:
|
||||||
return None, f"'{part}' is not accessible"
|
return None, f"'{part}' is not accessible"
|
||||||
obj: object = snapshot.as_mapping()
|
try:
|
||||||
for part in parts:
|
if isinstance(obj, Mapping):
|
||||||
if not _is_string_mapping(obj):
|
mapping = cast(Mapping[str, Any], obj)
|
||||||
return None, f"'{part}' not found"
|
if part in mapping:
|
||||||
if part not in obj:
|
obj = mapping[part]
|
||||||
|
else:
|
||||||
return None, f"'{part}' not found in mapping"
|
return None, f"'{part}' not found in mapping"
|
||||||
obj = obj[part]
|
else:
|
||||||
|
obj = getattr(obj, part)
|
||||||
|
except (KeyError, AttributeError) as e:
|
||||||
|
return None, f"'{part}' not found: {e}"
|
||||||
return obj, None
|
return obj, None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -245,48 +242,20 @@ class MyTool(Tool):
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _format_status(
|
def _format_status(st: "SubagentStatus", indent: str = " ") -> str:
|
||||||
st: "SubagentStatus | Mapping[str, object]",
|
elapsed = time.monotonic() - st.started_at
|
||||||
indent: str = " ",
|
tool_summary = ", ".join(
|
||||||
) -> str:
|
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||||
if isinstance(st, Mapping):
|
) or "none"
|
||||||
started_at = st.get("started_at", time.monotonic())
|
|
||||||
raw_events = st.get("tool_events", [])
|
|
||||||
phase = st.get("phase", "unknown")
|
|
||||||
iteration = st.get("iteration", 0)
|
|
||||||
usage = st.get("usage", {})
|
|
||||||
error = st.get("error")
|
|
||||||
stop_reason = st.get("stop_reason")
|
|
||||||
else:
|
|
||||||
started_at = st.started_at
|
|
||||||
raw_events = st.tool_events
|
|
||||||
phase = st.phase
|
|
||||||
iteration = st.iteration
|
|
||||||
usage = st.usage
|
|
||||||
error = st.error
|
|
||||||
stop_reason = st.stop_reason
|
|
||||||
elapsed = time.monotonic() - (
|
|
||||||
float(started_at) if isinstance(started_at, (int, float)) else time.monotonic()
|
|
||||||
)
|
|
||||||
tool_events = cast(list[object], raw_events) if isinstance(raw_events, list) else []
|
|
||||||
tool_summaries: list[str] = []
|
|
||||||
for raw_event in tool_events[-5:]:
|
|
||||||
if not isinstance(raw_event, Mapping):
|
|
||||||
continue
|
|
||||||
event = cast(Mapping[str, object], raw_event)
|
|
||||||
tool_summaries.append(
|
|
||||||
f"{event.get('name', '?')}({event.get('status', '?')})"
|
|
||||||
)
|
|
||||||
tool_summary = ", ".join(tool_summaries) or "none"
|
|
||||||
lines = [
|
lines = [
|
||||||
f"{indent}phase: {phase}, iteration: {iteration}, elapsed: {elapsed:.1f}s",
|
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||||
f"{indent}tools: {tool_summary}",
|
f"{indent}tools: {tool_summary}",
|
||||||
f"{indent}usage: {usage or 'n/a'}",
|
f"{indent}usage: {st.usage or 'n/a'}",
|
||||||
]
|
]
|
||||||
if error:
|
if st.error:
|
||||||
lines.append(f"{indent}error: {error}")
|
lines.append(f"{indent}error: {st.error}")
|
||||||
if stop_reason:
|
if st.stop_reason:
|
||||||
lines.append(f"{indent}stop_reason: {stop_reason}")
|
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -295,38 +264,29 @@ class MyTool(Tool):
|
|||||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||||
detail = MyTool._format_status(val, " ")
|
detail = MyTool._format_status(val, " ")
|
||||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||||
if _is_subagent_status_snapshot(val):
|
# SubagentManager: delegate to its _task_statuses dict
|
||||||
header = f"Subagent [{val['task_id']}] '{val['label']}'"
|
task_statuses = getattr(val, "_task_statuses", None)
|
||||||
detail = MyTool._format_status(val, " ")
|
if isinstance(task_statuses, dict):
|
||||||
return f"{header}\n task: {val['task_description']}\n{detail}"
|
return MyTool._format_value(task_statuses, key)
|
||||||
if isinstance(val, Mapping):
|
if isinstance(val, Mapping):
|
||||||
mapping = cast(Mapping[object, object], val)
|
mapping = cast(Mapping[object, object], val)
|
||||||
else:
|
else:
|
||||||
mapping = None
|
mapping = None
|
||||||
if mapping and set(mapping) == {"_task_statuses"}:
|
|
||||||
task_statuses = mapping["_task_statuses"]
|
|
||||||
if isinstance(task_statuses, Mapping):
|
|
||||||
return MyTool._format_value(task_statuses, key)
|
|
||||||
if (
|
if (
|
||||||
mapping
|
mapping
|
||||||
and (
|
and _is_subagent_status(next(iter(mapping.values())))
|
||||||
_is_subagent_status(next(iter(mapping.values())))
|
|
||||||
or _is_subagent_status_snapshot(next(iter(mapping.values())))
|
|
||||||
)
|
|
||||||
):
|
):
|
||||||
|
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
|
||||||
prefix = f"{key}: " if key else ""
|
prefix = f"{key}: " if key else ""
|
||||||
lines = [f"{prefix}{len(mapping)} subagent(s):"]
|
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
|
||||||
for tid, st in mapping.items():
|
for tid, st in status_mapping.items():
|
||||||
if _is_subagent_status(st):
|
|
||||||
detail = MyTool._format_status(st, " ")
|
detail = MyTool._format_status(st, " ")
|
||||||
label = st.label
|
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||||
elif _is_subagent_status_snapshot(st):
|
|
||||||
detail = MyTool._format_status(st, " ")
|
|
||||||
label = st.get("label", "?")
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
lines.append(f" [{tid}] '{label}'\n{detail}")
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
dynamic_value = cast(Any, val)
|
||||||
|
if hasattr(dynamic_value, "tool_names"):
|
||||||
|
tool_names: Any = getattr(dynamic_value, "tool_names")
|
||||||
|
return f"tools: {len(tool_names)} registered — {tool_names}"
|
||||||
# Scalar types — repr is fine
|
# Scalar types — repr is fine
|
||||||
if isinstance(val, (str, int, float, bool, type(None))):
|
if isinstance(val, (str, int, float, bool, type(None))):
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
@@ -351,6 +311,32 @@ class MyTool(Tool):
|
|||||||
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
|
||||||
r = repr(sequence)
|
r = repr(sequence)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
|
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||||
|
value_type = type(cast(object, val))
|
||||||
|
cls_name = value_type.__name__
|
||||||
|
model_fields = cast(object, getattr(value_type, "model_fields", None))
|
||||||
|
if isinstance(model_fields, Mapping) and model_fields:
|
||||||
|
fields = list(cast(Mapping[str, object], model_fields).keys())
|
||||||
|
if len(fields) <= 8:
|
||||||
|
# Small config objects: show field=value pairs
|
||||||
|
pairs: list[str] = []
|
||||||
|
for f in fields:
|
||||||
|
fv = getattr(val, f, "?")
|
||||||
|
if MyTool._is_sensitive_field_name(f):
|
||||||
|
continue
|
||||||
|
if isinstance(fv, (str, int, float, bool, type(None))):
|
||||||
|
pairs.append(f"{f}={fv!r}")
|
||||||
|
else:
|
||||||
|
pairs.append(f"{f}=<{type(fv).__name__}>")
|
||||||
|
preview = ", ".join(pairs)
|
||||||
|
return f"{key}: {preview}" if key else preview
|
||||||
|
else:
|
||||||
|
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
|
||||||
|
fields = [name for name in attributes if not name.startswith("__")]
|
||||||
|
if fields:
|
||||||
|
preview = ", ".join(str(f) for f in fields[:20])
|
||||||
|
suffix = ", ..." if len(fields) > 20 else ""
|
||||||
|
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
||||||
r = repr(val)
|
r = repr(val)
|
||||||
return f"{key}: {r}" if key else r
|
return f"{key}: {r}" if key else r
|
||||||
|
|
||||||
@@ -380,12 +366,7 @@ class MyTool(Tool):
|
|||||||
runtime = request_ctx.runtime if request_ctx is not None else None
|
runtime = request_ctx.runtime if request_ctx is not None else None
|
||||||
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
if runtime is None or key not in self._MODEL_RUNTIME_FIELDS:
|
||||||
return False, None
|
return False, None
|
||||||
values: dict[str, object] = {
|
return True, getattr(runtime, key)
|
||||||
"model": runtime.model,
|
|
||||||
"model_preset": runtime.model_preset,
|
|
||||||
"context_window_tokens": runtime.context_window_tokens,
|
|
||||||
}
|
|
||||||
return True, values[key]
|
|
||||||
|
|
||||||
def _inspect(self, key: str | None) -> str:
|
def _inspect(self, key: str | None) -> str:
|
||||||
if not key:
|
if not key:
|
||||||
@@ -394,64 +375,62 @@ class MyTool(Tool):
|
|||||||
request_ctx = current_request_context()
|
request_ctx = current_request_context()
|
||||||
if request_ctx is None:
|
if request_ctx is None:
|
||||||
return ToolResult.error("Error: current request context is unavailable")
|
return ToolResult.error("Error: current request context is unavailable")
|
||||||
request_values: dict[str, str | None] = {
|
|
||||||
"channel": request_ctx.channel,
|
|
||||||
"chat_id": request_ctx.chat_id,
|
|
||||||
"sender_id": request_ctx.sender_id,
|
|
||||||
}
|
|
||||||
if key == "request":
|
if key == "request":
|
||||||
return self._format_value(request_values, key)
|
return self._format_value(
|
||||||
|
{field: getattr(request_ctx, field) for field in self._REQUEST_FIELDS},
|
||||||
|
key,
|
||||||
|
)
|
||||||
field = key.removeprefix("request.")
|
field = key.removeprefix("request.")
|
||||||
if field not in self._REQUEST_FIELDS:
|
if field not in self._REQUEST_FIELDS:
|
||||||
return ToolResult.error(f"Error: '{key}' not found")
|
return ToolResult.error(f"Error: '{key}' not found")
|
||||||
return self._format_value(request_values[field], key)
|
return self._format_value(getattr(request_ctx, field), key)
|
||||||
if "." not in key:
|
if "." not in key:
|
||||||
found, value = self._current_runtime_value(key)
|
found, value = self._current_runtime_value(key)
|
||||||
if found:
|
if found:
|
||||||
return self._format_value(value, key)
|
return self._format_value(value, key)
|
||||||
snapshot = self._runtime_control.snapshot()
|
|
||||||
top = key.split(".")[0]
|
top = key.split(".")[0]
|
||||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||||
return ToolResult.error(f"Error: '{top}' is not accessible")
|
return ToolResult.error(f"Error: '{top}' is not accessible")
|
||||||
obj, err = self._resolve_path(snapshot, key)
|
obj, err = self._resolve_path(key)
|
||||||
if err:
|
if err:
|
||||||
|
# "scratchpad" alias for _runtime_vars
|
||||||
if key == "scratchpad":
|
if key == "scratchpad":
|
||||||
return (
|
rv = self._runtime_state._runtime_vars
|
||||||
self._format_value(snapshot.scratchpad, "scratchpad")
|
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||||
if snapshot.scratchpad
|
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||||
else "scratchpad is empty"
|
if "." not in key and key in self._runtime_state._runtime_vars:
|
||||||
)
|
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||||
if "." not in key and key in snapshot.scratchpad:
|
|
||||||
return self._format_value(snapshot.scratchpad[key], key)
|
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
|
# Guard against mock auto-generated attributes
|
||||||
|
if "." not in key and not _has_real_attr(self._runtime_state, key):
|
||||||
|
if key in self._runtime_state._runtime_vars:
|
||||||
|
return self._format_value(self._runtime_state._runtime_vars[key], key)
|
||||||
|
return ToolResult.error(f"Error: '{key}' not found")
|
||||||
return self._format_value(obj, key)
|
return self._format_value(obj, key)
|
||||||
|
|
||||||
def _inspect_all(self) -> str:
|
def _inspect_all(self) -> str:
|
||||||
snapshot = self._runtime_control.snapshot()
|
state = self._runtime_state
|
||||||
values = snapshot.as_mapping()
|
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
|
# RESTRICTED keys
|
||||||
for k in self.RESTRICTED:
|
for k in self.RESTRICTED:
|
||||||
found, value = self._current_runtime_value(k)
|
found, value = self._current_runtime_value(k)
|
||||||
parts.append(self._format_value(value if found else values[k], k))
|
parts.append(self._format_value(value if found else getattr(state, k, None), k))
|
||||||
found, value = self._current_runtime_value("model_preset")
|
found, value = self._current_runtime_value("model_preset")
|
||||||
parts.append(self._format_value(
|
parts.append(self._format_value(
|
||||||
value if found else snapshot.model_preset,
|
value if found else state.model_preset,
|
||||||
"model_preset",
|
"model_preset",
|
||||||
))
|
))
|
||||||
for k in (
|
# Other useful top-level keys shown in description
|
||||||
"workspace",
|
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "workspace_sandbox", "subagents"):
|
||||||
"provider_retry_mode",
|
if _has_real_attr(state, k):
|
||||||
"max_tool_result_chars",
|
parts.append(self._format_value(getattr(state, k, None), k))
|
||||||
"_current_iteration",
|
# Token usage
|
||||||
"web_config",
|
usage = state._last_usage
|
||||||
"exec_config",
|
if usage:
|
||||||
"subagents",
|
parts.append(self._format_value(usage, "_last_usage"))
|
||||||
):
|
rv = state._runtime_vars
|
||||||
parts.append(self._format_value(values[k], k))
|
if rv:
|
||||||
if snapshot.last_usage:
|
parts.append(self._format_value(rv, "scratchpad"))
|
||||||
parts.append(self._format_value(snapshot.last_usage, "_last_usage"))
|
|
||||||
if snapshot.scratchpad:
|
|
||||||
parts.append(self._format_value(snapshot.scratchpad, "scratchpad"))
|
|
||||||
return "\n".join(parts)
|
return "\n".join(parts)
|
||||||
|
|
||||||
# -- modify --
|
# -- modify --
|
||||||
@@ -475,49 +454,48 @@ class MyTool(Tool):
|
|||||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||||
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
return ToolResult.error(f"Error: '{leaf}' is not accessible")
|
||||||
snapshot = self._runtime_control.snapshot()
|
parent, err = self._resolve_path(parent_path)
|
||||||
_parent, err = self._resolve_path(snapshot, parent_path)
|
|
||||||
if err:
|
if err:
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
self._audit("modify", f"READ_ONLY {key}")
|
if isinstance(parent, dict):
|
||||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
parent[leaf] = value
|
||||||
|
else:
|
||||||
|
setattr(parent, leaf, value)
|
||||||
|
self._audit("modify", f"{key} = {value!r}")
|
||||||
|
return f"Set {key} = {value!r}"
|
||||||
if key == "model_preset":
|
if key == "model_preset":
|
||||||
return self._modify_model_preset(value)
|
return self._modify_model_preset(value)
|
||||||
if key in self.RESTRICTED:
|
if key in self.RESTRICTED:
|
||||||
return self._modify_restricted(key, value)
|
return self._modify_restricted(key, value)
|
||||||
if key in RUNTIME_COMMAND_KEYS:
|
return self._modify_free(key, value)
|
||||||
return self._modify_runtime_setting(key, value)
|
|
||||||
if key in RUNTIME_SNAPSHOT_KEYS:
|
|
||||||
self._audit("modify", f"READ_ONLY {key}")
|
|
||||||
return ToolResult.error(f"Error: '{key}' is read-only and cannot be modified")
|
|
||||||
return self._modify_scratchpad(key, value)
|
|
||||||
|
|
||||||
def _modify_model_preset(self, value: Any) -> str:
|
def _modify_model_preset(self, value: Any) -> str:
|
||||||
if not isinstance(value, str) or not value.strip():
|
if not isinstance(value, str) or not value.strip():
|
||||||
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
return ToolResult.error("Error: 'model_preset' must be a non-empty string")
|
||||||
name = value.strip()
|
name = value.strip()
|
||||||
session_key = current_request_session_key()
|
session_key = current_request_session_key()
|
||||||
old = self._runtime_control.snapshot().model_preset
|
if session_key:
|
||||||
try:
|
try:
|
||||||
runtime = self._runtime_control.set_model_preset(
|
runtime = self._runtime_state.set_session_model_preset(
|
||||||
|
session_key,
|
||||||
name,
|
name,
|
||||||
session_key=session_key,
|
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as exc:
|
except (KeyError, ValueError) as exc:
|
||||||
message = str(exc.args[0]) if exc.args else str(exc)
|
message = str(exc.args[0]) if exc.args else str(exc)
|
||||||
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
punctuation = "" if message.endswith((".", "!", "?")) else "."
|
||||||
return ToolResult.error(f"Error: {message}{punctuation}")
|
return ToolResult.error(f"Error: {message}{punctuation}")
|
||||||
if session_key:
|
|
||||||
self._audit("modify", f"model_preset = {name!r}")
|
self._audit("modify", f"model_preset = {name!r}")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} for the next turn; "
|
f"Set model_preset = {name!r} for the next turn; "
|
||||||
f"model will be {runtime.model!r}; "
|
f"model will be {runtime.model!r}; "
|
||||||
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
f"context_window_tokens will be {runtime.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
self._audit("modify", f"model_preset: {old!r} -> {name!r}")
|
result = self._modify_free("model_preset", name)
|
||||||
|
if isinstance(result, ToolResult) and result.is_error:
|
||||||
|
return result if result.endswith((".", "!", "?")) else ToolResult.error(f"{result}.")
|
||||||
return (
|
return (
|
||||||
f"Set model_preset = {name!r} (was {old!r}); model is now {runtime.model!r}; "
|
f"{result}; model is now {self._runtime_state.model!r}; "
|
||||||
f"context_window_tokens is now {runtime.context_window_tokens!r}"
|
f"context_window_tokens is now {self._runtime_state.context_window_tokens!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||||
@@ -530,7 +508,7 @@ class MyTool(Tool):
|
|||||||
value = expected(value)
|
value = expected(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}")
|
||||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
old = getattr(self._runtime_state, key)
|
||||||
if "min" in spec and value < spec["min"]:
|
if "min" in spec and value < spec["min"]:
|
||||||
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
return ToolResult.error(f"Error: '{key}' must be >= {spec['min']}")
|
||||||
if "max" in spec and value > spec["max"]:
|
if "max" in spec and value > spec["max"]:
|
||||||
@@ -543,46 +521,41 @@ class MyTool(Tool):
|
|||||||
"during an active session; use a configured model_preset"
|
"during an active session; use a configured model_preset"
|
||||||
)
|
)
|
||||||
if key == "model":
|
if key == "model":
|
||||||
self._runtime_control.set_model(cast(str, value))
|
self._runtime_state.set_runtime_model(cast(str, value))
|
||||||
elif key == "context_window_tokens":
|
elif key == "context_window_tokens":
|
||||||
self._runtime_control.set_context_window_tokens(cast(int, value))
|
self._runtime_state.set_runtime_context_window(cast(int, value))
|
||||||
else:
|
else:
|
||||||
self._runtime_control.set_max_iterations(cast(int, value))
|
setattr(self._runtime_state, key, value)
|
||||||
|
if key == "max_iterations" and hasattr(
|
||||||
|
self._runtime_state,
|
||||||
|
"_sync_subagent_runtime_limits",
|
||||||
|
):
|
||||||
|
self._runtime_state._sync_subagent_runtime_limits()
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
def _modify_runtime_setting(self, key: str, value: Any) -> str:
|
def _modify_free(self, key: str, value: Any) -> str:
|
||||||
old = self._runtime_control.snapshot().as_mapping()[key]
|
if _has_real_attr(self._runtime_state, key):
|
||||||
if key == "workspace":
|
old = getattr(self._runtime_state, key)
|
||||||
if not isinstance(value, str):
|
if isinstance(old, (str, int, float, bool)):
|
||||||
return ToolResult.error(
|
old_t: type[Any] = type(old)
|
||||||
f"Error: 'workspace' expects str, got {type(value).__name__}"
|
|
||||||
)
|
|
||||||
self._runtime_control.set_workspace_display(value)
|
|
||||||
self._audit("modify", f"workspace: {old!r} -> {value!r}")
|
|
||||||
return f"Set workspace = {value!r} (was {old!r})"
|
|
||||||
old_t = type(old)
|
|
||||||
new_t = cast(type[Any], type(value))
|
new_t = cast(type[Any], type(value))
|
||||||
if old_t is float and new_t is int:
|
if old_t is float and new_t is int:
|
||||||
pass
|
pass # int → float coercion allowed
|
||||||
elif old_t is not new_t:
|
elif old_t is not new_t:
|
||||||
self._audit(
|
self._audit(
|
||||||
"modify",
|
"modify",
|
||||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||||
)
|
)
|
||||||
return ToolResult.error(
|
return ToolResult.error(f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}")
|
||||||
f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
try:
|
||||||
)
|
setattr(self._runtime_state, key, value)
|
||||||
if key == "provider_retry_mode":
|
except (ValueError, KeyError) as e:
|
||||||
self._runtime_control.set_provider_retry_mode(cast(str, value))
|
message = str(e.args[0] if isinstance(e, KeyError) and e.args else e).strip('"')
|
||||||
elif key == "max_tool_result_chars":
|
self._audit("modify", f"REJECTED {key}: {message}")
|
||||||
self._runtime_control.set_max_tool_result_chars(cast(int, value))
|
return ToolResult.error(f"Error: {message}")
|
||||||
else:
|
|
||||||
raise AssertionError(f"Unhandled runtime command: {key}")
|
|
||||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||||
return f"Set {key} = {value!r} (was {old!r})"
|
return f"Set {key} = {value!r} (was {old!r})"
|
||||||
|
|
||||||
def _modify_scratchpad(self, key: str, value: Any) -> str:
|
|
||||||
if callable(value):
|
if callable(value):
|
||||||
self._audit("modify", f"REJECTED callable {key}")
|
self._audit("modify", f"REJECTED callable {key}")
|
||||||
return ToolResult.error("Error: cannot store callable values")
|
return ToolResult.error("Error: cannot store callable values")
|
||||||
@@ -590,16 +563,12 @@ class MyTool(Tool):
|
|||||||
if err:
|
if err:
|
||||||
self._audit("modify", f"REJECTED {key}: {err}")
|
self._audit("modify", f"REJECTED {key}: {err}")
|
||||||
return ToolResult.error(f"Error: {err}")
|
return ToolResult.error(f"Error: {err}")
|
||||||
try:
|
if key not in self._runtime_state._runtime_vars and len(self._runtime_state._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||||
self._runtime_control.set_scratchpad(
|
|
||||||
key,
|
|
||||||
cast(JsonValue, value),
|
|
||||||
max_keys=self._MAX_RUNTIME_KEYS,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||||
return ToolResult.error(f"Error: {exc}. Remove unused keys first.")
|
return ToolResult.error(f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first.")
|
||||||
self._audit("modify", f"scratchpad.{key} = {value!r}")
|
old = self._runtime_state._runtime_vars.get(key)
|
||||||
|
self._runtime_state._runtime_vars[key] = value
|
||||||
|
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||||
return f"Set scratchpad.{key} = {value!r}"
|
return f"Set scratchpad.{key} = {value!r}"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -453,15 +453,12 @@ class WebSearchTool(Tool):
|
|||||||
|
|
||||||
async def _search_olostep(self, query: str, n: int) -> str:
|
async def _search_olostep(self, query: str, n: int) -> str:
|
||||||
try:
|
try:
|
||||||
from olostep import ( # pyright: ignore[reportMissingImports, reportMissingTypeStubs]
|
from olostep import ( # pyright: ignore[reportMissingImports]
|
||||||
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
|
||||||
Olostep_BaseError, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType]
|
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return ToolResult.error(
|
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
|
||||||
"Error: Olostep support is not installed. "
|
|
||||||
"Run `nanobot plugins enable olostep`."
|
|
||||||
)
|
|
||||||
async_olostep = cast(Any, AsyncOlostep)
|
async_olostep = cast(Any, AsyncOlostep)
|
||||||
olostep_base_error = cast(type[Exception], Olostep_BaseError)
|
olostep_base_error = cast(type[Exception], Olostep_BaseError)
|
||||||
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
|
|||||||
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
|
||||||
|
"""Return model-visible CLI app annotations for the current turn."""
|
||||||
|
if skip:
|
||||||
|
return []
|
||||||
|
text = message.content if isinstance(getattr(message, "content", None), str) else ""
|
||||||
|
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
|
||||||
|
return runtime_lines_for_request(text, metadata, workspace)
|
||||||
|
|
||||||
|
|
||||||
def runtime_lines_for_request(
|
def runtime_lines_for_request(
|
||||||
text: str,
|
text: str,
|
||||||
metadata: Mapping[str, Any] | None,
|
metadata: Mapping[str, Any] | None,
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
|
|||||||
RUNTIME_CONTROL_ACK = "_ack"
|
RUNTIME_CONTROL_ACK = "_ack"
|
||||||
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
|
||||||
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -33,7 +32,6 @@ class InboundMessage:
|
|||||||
media: list[str] = field(default_factory=list) # Media URLs
|
media: list[str] = field(default_factory=list) # Media URLs
|
||||||
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
|
||||||
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
session_key_override: str | None = None # Optional override for thread-scoped sessions
|
||||||
require_existing_session: bool = False
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def session_key(self) -> str:
|
def session_key(self) -> str:
|
||||||
|
|||||||
@@ -101,31 +101,6 @@ class BaseChannel(ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def progress_transport_defaults(self) -> tuple[bool, bool] | None:
|
|
||||||
"""Return channel-owned defaults for progress and tool-hint messages.
|
|
||||||
|
|
||||||
``None`` keeps the global channel policy. Channels should override this
|
|
||||||
only when their transport requires different defaults.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
def should_retry_send_error(self, error: Exception) -> bool:
|
|
||||||
"""Return whether the channel manager may retry a failed delivery.
|
|
||||||
|
|
||||||
Channels with protocol-level business errors can override this hook to
|
|
||||||
prevent retries that cannot succeed until external state changes.
|
|
||||||
Transport and unexpected errors remain retryable by default.
|
|
||||||
"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
def start_error_message(self, error: Exception) -> str | None:
|
|
||||||
"""Return an actionable public message for a channel startup failure.
|
|
||||||
|
|
||||||
Channel-specific exception handling stays in the owning channel. Returning
|
|
||||||
``None`` keeps the manager's generic fallback.
|
|
||||||
"""
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def send_delta(
|
async def send_delta(
|
||||||
self,
|
self,
|
||||||
chat_id: str,
|
chat_id: str,
|
||||||
@@ -262,7 +237,6 @@ class BaseChannel(ABC):
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
authorization_id: str | None = None,
|
authorization_id: str | None = None,
|
||||||
require_existing_session: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle a message after checking its authorization subject.
|
"""Handle a message after checking its authorization subject.
|
||||||
|
|
||||||
@@ -315,7 +289,6 @@ class BaseChannel(ABC):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
session_key_override=session_key,
|
||||||
require_existing_session=require_existing_session,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.bus.publish_inbound(msg)
|
await self.bus.publish_inbound(msg)
|
||||||
|
|||||||
@@ -470,6 +470,15 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
|
|||||||
return "", []
|
return "", []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
|
||||||
|
"""Extract plain text from Feishu post (rich text) message content.
|
||||||
|
|
||||||
|
Legacy wrapper for _extract_post_content, returns only text.
|
||||||
|
"""
|
||||||
|
text, _ = _extract_post_content(content_json)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# QR scan-to-create onboarding
|
# QR scan-to-create onboarding
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -238,6 +238,20 @@ class TestStreamEndReactionCleanup:
|
|||||||
|
|
||||||
ch._remove_reaction.assert_not_called()
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_removal_when_both_ids_missing(self):
|
||||||
|
ch = _make_channel()
|
||||||
|
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
|
||||||
|
text="Done", card_id="card_1", sequence=3, last_edit=0.0,
|
||||||
|
)
|
||||||
|
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", "", stream_end=True)
|
||||||
|
|
||||||
|
ch._remove_reaction.assert_not_called()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_no_removal_when_not_stream_end(self):
|
async def test_no_removal_when_not_stream_end(self):
|
||||||
ch = _make_channel()
|
ch = _make_channel()
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import type {
|
|||||||
NanobotFeatureInfo,
|
NanobotFeatureInfo,
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
import { FeishuConnectFlow } from "./FeishuConnectFlow";
|
||||||
|
|
||||||
@@ -34,6 +33,7 @@ export function FeishuAssistantsPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelInstancesPanel
|
<ChannelInstancesPanel
|
||||||
|
token={token}
|
||||||
feature={feature}
|
feature={feature}
|
||||||
showBrandLogos={showBrandLogos}
|
showBrandLogos={showBrandLogos}
|
||||||
chatAppsDocsUrl={chatAppsDocsUrl}
|
chatAppsDocsUrl={chatAppsDocsUrl}
|
||||||
@@ -92,7 +92,6 @@ function FeishuInstanceAction({
|
|||||||
instance: NanobotChannelInstanceInfo;
|
instance: NanobotChannelInstanceInfo;
|
||||||
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { client } = useClient();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = channelTranslator(t, "feishu");
|
const tx = channelTranslator(t, "feishu");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
@@ -115,7 +114,7 @@ function FeishuInstanceAction({
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
onFeaturesUpdate(
|
onFeaturesUpdate(
|
||||||
await enableNanobotFeature(client, "feishu", { instanceId: instance.id }),
|
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@@ -101,14 +101,8 @@ class ChannelManager:
|
|||||||
webui_runtime_surface: str = "browser",
|
webui_runtime_surface: str = "browser",
|
||||||
webui_runtime_capabilities: dict[str, Any] | None = None,
|
webui_runtime_capabilities: dict[str, Any] | None = None,
|
||||||
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
webui_skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
config_path: Path | None = None,
|
|
||||||
):
|
):
|
||||||
if config_path is None:
|
|
||||||
from nanobot.config.loader import get_config_path
|
|
||||||
|
|
||||||
config_path = get_config_path()
|
|
||||||
self.config = config
|
self.config = config
|
||||||
self._config_path = config_path.expanduser().resolve(strict=False)
|
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self._session_manager = session_manager
|
self._session_manager = session_manager
|
||||||
self._cron_service = cron_service
|
self._cron_service = cron_service
|
||||||
@@ -176,7 +170,6 @@ class ChannelManager:
|
|||||||
static_dist_path=static_path,
|
static_dist_path=static_path,
|
||||||
workspace_path=workspace,
|
workspace_path=workspace,
|
||||||
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
default_restrict_to_workspace=self.config.tools.restrict_to_workspace,
|
||||||
config_path=self._config_path,
|
|
||||||
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
disabled_skills=set(self.config.agents.defaults.disabled_skills),
|
||||||
runtime_model_name=self._webui_runtime_model_name,
|
runtime_model_name=self._webui_runtime_model_name,
|
||||||
runtime_surface=self._webui_runtime_surface,
|
runtime_surface=self._webui_runtime_surface,
|
||||||
@@ -194,15 +187,11 @@ class ChannelManager:
|
|||||||
channel = cls(section, self.bus, **kwargs)
|
channel = cls(section, self.bus, **kwargs)
|
||||||
if runtime_name and runtime_name != channel.name:
|
if runtime_name and runtime_name != channel.name:
|
||||||
channel.name = runtime_name
|
channel.name = runtime_name
|
||||||
progress_default, tool_hints_default = channel.progress_transport_defaults() or (
|
|
||||||
self.config.channels.send_progress,
|
|
||||||
self.config.channels.send_tool_hints,
|
|
||||||
)
|
|
||||||
channel.send_progress = self._resolve_bool_override(
|
channel.send_progress = self._resolve_bool_override(
|
||||||
section, "send_progress", progress_default,
|
section, "send_progress", self.config.channels.send_progress,
|
||||||
)
|
)
|
||||||
channel.send_tool_hints = self._resolve_bool_override(
|
channel.send_tool_hints = self._resolve_bool_override(
|
||||||
section, "send_tool_hints", tool_hints_default,
|
section, "send_tool_hints", self.config.channels.send_tool_hints,
|
||||||
)
|
)
|
||||||
channel.show_reasoning = self._resolve_bool_override(
|
channel.show_reasoning = self._resolve_bool_override(
|
||||||
section, "show_reasoning", self.config.channels.show_reasoning,
|
section, "show_reasoning", self.config.channels.show_reasoning,
|
||||||
@@ -358,12 +347,8 @@ class ChannelManager:
|
|||||||
await channel.start()
|
await channel.start()
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
public_error = channel.start_error_message(exc)
|
errors[name] = "Channel failed to start. Check gateway logs."
|
||||||
errors[name] = public_error or "Channel failed to start. Check gateway logs."
|
|
||||||
if public_error:
|
|
||||||
logger.error("Failed to start channel {}: {}", name, public_error)
|
|
||||||
else:
|
|
||||||
logger.exception("Failed to start channel {}", name)
|
logger.exception("Failed to start channel {}", name)
|
||||||
|
|
||||||
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
|
def _start_channel_task(self, name: str, channel: BaseChannel) -> asyncio.Task[None]:
|
||||||
@@ -927,14 +912,6 @@ class ChannelManager:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
raise # Propagate cancellation for graceful shutdown
|
raise # Propagate cancellation for graceful shutdown
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not channel.should_retry_send_error(e):
|
|
||||||
logger.error(
|
|
||||||
"Send to {} failed with a non-retryable {}: {}",
|
|
||||||
msg.channel,
|
|
||||||
type(e).__name__,
|
|
||||||
e,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
exhausted = (
|
exhausted = (
|
||||||
attempt >= max_attempts
|
attempt >= max_attempts
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ try:
|
|||||||
import nh3
|
import nh3
|
||||||
from mistune import HTMLRenderer, create_markdown
|
from mistune import HTMLRenderer, create_markdown
|
||||||
from nio import (
|
from nio import (
|
||||||
Api,
|
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncClientConfig,
|
AsyncClientConfig,
|
||||||
InviteEvent,
|
InviteEvent,
|
||||||
JoinError,
|
JoinError,
|
||||||
JoinResponse,
|
|
||||||
KeyVerificationCancel,
|
KeyVerificationCancel,
|
||||||
KeyVerificationEvent,
|
KeyVerificationEvent,
|
||||||
KeyVerificationKey,
|
KeyVerificationKey,
|
||||||
@@ -45,7 +43,6 @@ try:
|
|||||||
RoomSendResponse,
|
RoomSendResponse,
|
||||||
RoomTypingError,
|
RoomTypingError,
|
||||||
SyncError,
|
SyncError,
|
||||||
SyncResponse,
|
|
||||||
ToDeviceError,
|
ToDeviceError,
|
||||||
UploadError,
|
UploadError,
|
||||||
)
|
)
|
||||||
@@ -704,7 +701,6 @@ class MatrixChannel(BaseChannel):
|
|||||||
client.add_response_callback(self._on_sync_error, SyncError)
|
client.add_response_callback(self._on_sync_error, SyncError)
|
||||||
client.add_response_callback(self._on_join_error, JoinError)
|
client.add_response_callback(self._on_join_error, JoinError)
|
||||||
client.add_response_callback(self._on_send_error, RoomSendError)
|
client.add_response_callback(self._on_send_error, RoomSendError)
|
||||||
client.add_response_callback(self._on_sync_invite_fallback, SyncResponse)
|
|
||||||
|
|
||||||
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
def _is_sas_sender_allowed(self, sender: str) -> bool:
|
||||||
return bool(sender and self.is_allowed(sender))
|
return bool(sender and self.is_allowed(sender))
|
||||||
@@ -786,49 +782,6 @@ class MatrixChannel(BaseChannel):
|
|||||||
with suppress(Exception):
|
with suppress(Exception):
|
||||||
self.client.stop_sync_forever()
|
self.client.stop_sync_forever()
|
||||||
|
|
||||||
async def _join_room_safe(self, room_id: str) -> bool:
|
|
||||||
"""Join a room, sending a non-empty POST body.
|
|
||||||
|
|
||||||
nio's ``Api.join()`` produces a POST with no body. Some homeservers
|
|
||||||
(notably Continuwuity) reject empty bodies with ``M_BAD_JSON``.
|
|
||||||
Sending ``"{}"`` satisfies both strict and lenient servers.
|
|
||||||
"""
|
|
||||||
client = self._require_client()
|
|
||||||
method, path = Api.join(client.access_token, room_id)
|
|
||||||
try:
|
|
||||||
resp = cast(
|
|
||||||
JoinResponse | JoinError,
|
|
||||||
await client._send( # type: ignore[reportPrivateUsage, reportUnknownMemberType]
|
|
||||||
JoinResponse, method, path, data="{}"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.logger.error("Matrix join request exception for room={}", room_id, exc_info=True)
|
|
||||||
return False
|
|
||||||
if isinstance(resp, JoinError):
|
|
||||||
self.logger.error("Matrix auto-join failed for room={}: {}", room_id, resp)
|
|
||||||
return False
|
|
||||||
self.logger.info("Matrix auto-join succeeded: {}", room_id)
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def _on_sync_invite_fallback(self, response: SyncResponse) -> None:
|
|
||||||
"""Safety net: join pending invites that the event callback may have missed.
|
|
||||||
|
|
||||||
Some homeservers (e.g. Continuwuity) deliver each invite only once.
|
|
||||||
If ``_on_room_invite`` fires but the join fails, the sync token
|
|
||||||
advances and the invite is never re-delivered. This callback inspects
|
|
||||||
the same ``SyncResponse`` for pending invites and joins them, acting
|
|
||||||
as a fallback alongside the event-based callback.
|
|
||||||
"""
|
|
||||||
if not response.rooms or not response.rooms.invite:
|
|
||||||
return
|
|
||||||
for room_id, invite_info in response.rooms.invite.items():
|
|
||||||
for event in cast(list[Any], invite_info.invite_state):
|
|
||||||
sender = getattr(event, "sender", None)
|
|
||||||
if sender and self.is_allowed(cast(str, sender)):
|
|
||||||
await self._join_room_safe(room_id)
|
|
||||||
break
|
|
||||||
|
|
||||||
async def _on_join_error(self, response: JoinError) -> None:
|
async def _on_join_error(self, response: JoinError) -> None:
|
||||||
self._log_response_error("join", response)
|
self._log_response_error("join", response)
|
||||||
|
|
||||||
@@ -885,7 +838,8 @@ class MatrixChannel(BaseChannel):
|
|||||||
|
|
||||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||||
if self.is_allowed(event.sender):
|
if self.is_allowed(event.sender):
|
||||||
await self._join_room_safe(room.room_id)
|
client = self._require_client()
|
||||||
|
await client.join(room.room_id)
|
||||||
|
|
||||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||||
count = getattr(room, "member_count", None)
|
count = getattr(room, "member_count", None)
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import asyncio
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("nio")
|
pytest.importorskip("nio")
|
||||||
pytest.importorskip("nh3")
|
pytest.importorskip("nh3")
|
||||||
pytest.importorskip("mistune")
|
pytest.importorskip("mistune")
|
||||||
from nio import JoinResponse, RoomSendResponse, SyncError
|
from nio import RoomSendResponse, SyncError
|
||||||
|
|
||||||
import nanobot.channels.matrix.runtime as matrix_module
|
import nanobot.channels.matrix.runtime as matrix_module
|
||||||
from nanobot.bus.events import OutboundMessage
|
from nanobot.bus.events import OutboundMessage
|
||||||
@@ -105,15 +104,6 @@ class _FakeAsyncClient:
|
|||||||
async def join(self, room_id: str) -> None:
|
async def join(self, room_id: str) -> None:
|
||||||
self.join_calls.append(room_id)
|
self.join_calls.append(room_id)
|
||||||
|
|
||||||
async def _send(self, response_class, method, path, data=None, **kwargs):
|
|
||||||
"""Minimal mock for nio's ``_send`` used by ``_join_room_safe``."""
|
|
||||||
if response_class is JoinResponse and method == "POST" and "/join/" in path:
|
|
||||||
encoded = path.split("/join/")[1].split("?")[0]
|
|
||||||
room_id = unquote(encoded)
|
|
||||||
self.join_calls.append(room_id)
|
|
||||||
return JoinResponse(room_id=room_id)
|
|
||||||
return response_class()
|
|
||||||
|
|
||||||
async def accept_key_verification(self, transaction_id: str):
|
async def accept_key_verification(self, transaction_id: str):
|
||||||
self.operation_calls.append(f"accept:{transaction_id}")
|
self.operation_calls.append(f"accept:{transaction_id}")
|
||||||
self.accept_key_verification_calls.append(transaction_id)
|
self.accept_key_verification_calls.append(transaction_id)
|
||||||
@@ -318,7 +308,7 @@ async def test_start_skips_load_store_when_device_id_missing(
|
|||||||
assert clients[0].load_store_called is False
|
assert clients[0].load_store_called is False
|
||||||
assert len(clients[0].callbacks) == 3
|
assert len(clients[0].callbacks) == 3
|
||||||
assert clients[0].to_device_callbacks == []
|
assert clients[0].to_device_callbacks == []
|
||||||
assert len(clients[0].response_callbacks) == 4
|
assert len(clients[0].response_callbacks) == 3
|
||||||
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
|
|
||||||
@@ -600,7 +590,6 @@ async def test_room_invite_joins_when_sender_allowed() -> None:
|
|||||||
|
|
||||||
assert client.join_calls == ["!room:matrix.org"]
|
assert client.join_calls == ["!room:matrix.org"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
||||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
||||||
@@ -615,61 +604,6 @@ async def test_room_invite_respects_allow_list_when_configured() -> None:
|
|||||||
assert client.join_calls == []
|
assert client.join_calls == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_sync_invite_fallback_joins_pending_invites() -> None:
|
|
||||||
"""_on_sync_invite_fallback joins rooms from sync invite_state for allowed senders."""
|
|
||||||
channel = MatrixChannel(
|
|
||||||
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
|
|
||||||
)
|
|
||||||
client = _FakeAsyncClient("", "", "", None)
|
|
||||||
channel.client = client
|
|
||||||
|
|
||||||
invite_event = SimpleNamespace(sender="@alice:matrix.org")
|
|
||||||
invite_info = SimpleNamespace(invite_state=[invite_event])
|
|
||||||
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
|
|
||||||
response = SimpleNamespace(rooms=rooms)
|
|
||||||
|
|
||||||
await channel._on_sync_invite_fallback(response)
|
|
||||||
|
|
||||||
assert client.join_calls == ["!room:matrix.org"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_sync_invite_fallback_skips_when_no_invites() -> None:
|
|
||||||
"""_on_sync_invite_fallback is a no-op when sync has no invites."""
|
|
||||||
channel = MatrixChannel(
|
|
||||||
_make_config(allow_from=["@alice:matrix.org"]), MessageBus()
|
|
||||||
)
|
|
||||||
client = _FakeAsyncClient("", "", "", None)
|
|
||||||
channel.client = client
|
|
||||||
|
|
||||||
rooms = SimpleNamespace(invite={})
|
|
||||||
response = SimpleNamespace(rooms=rooms)
|
|
||||||
|
|
||||||
await channel._on_sync_invite_fallback(response)
|
|
||||||
|
|
||||||
assert client.join_calls == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_on_sync_invite_fallback_skips_denied_sender() -> None:
|
|
||||||
"""_on_sync_invite_fallback respects the allow list."""
|
|
||||||
channel = MatrixChannel(
|
|
||||||
_make_config(allow_from=["@bob:matrix.org"]), MessageBus()
|
|
||||||
)
|
|
||||||
client = _FakeAsyncClient("", "", "", None)
|
|
||||||
channel.client = client
|
|
||||||
|
|
||||||
invite_event = SimpleNamespace(sender="@alice:matrix.org")
|
|
||||||
invite_info = SimpleNamespace(invite_state=[invite_event])
|
|
||||||
rooms = SimpleNamespace(invite={"!room:matrix.org": invite_info})
|
|
||||||
response = SimpleNamespace(rooms=rooms)
|
|
||||||
|
|
||||||
await channel._on_sync_invite_fallback(response)
|
|
||||||
|
|
||||||
assert client.join_calls == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_on_message_sets_typing_for_allowed_sender() -> None:
|
async def test_on_message_sets_typing_for_allowed_sender() -> None:
|
||||||
channel = MatrixChannel(_make_config(), MessageBus())
|
channel = MatrixChannel(_make_config(), MessageBus())
|
||||||
|
|||||||
@@ -658,6 +658,11 @@ class MattermostChannel(BaseChannel):
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
return cast(dict[str, Any], resp.json())
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
|
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
resp = await self._require_http_client().put(path, json=json_data)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return cast(dict[str, Any], resp.json())
|
||||||
|
|
||||||
async def _create_post(
|
async def _create_post(
|
||||||
self,
|
self,
|
||||||
channel_id: str,
|
channel_id: str,
|
||||||
@@ -676,6 +681,9 @@ class MattermostChannel(BaseChannel):
|
|||||||
body["file_ids"] = file_ids
|
body["file_ids"] = file_ids
|
||||||
return await self._api_post("/api/v4/posts", body)
|
return await self._api_post("/api/v4/posts", body)
|
||||||
|
|
||||||
|
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
|
||||||
|
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
|
||||||
|
|
||||||
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
|
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
|
||||||
path = Path(file_path)
|
path = Path(file_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
|
|||||||
@@ -811,6 +811,11 @@ class MSTeamsChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("Failed to save conversation refs: {}", e)
|
self.logger.warning("Failed to save 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:
|
async def _get_access_token(self) -> str:
|
||||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||||
|
|
||||||
|
|||||||
@@ -228,8 +228,7 @@ def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monke
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
with ch._refs_guard:
|
ch._save_refs()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
|
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
|
||||||
|
|
||||||
@@ -379,8 +378,7 @@ def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_
|
|||||||
raise OSError("replace failed")
|
raise OSError("replace failed")
|
||||||
|
|
||||||
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
|
||||||
with ch._refs_guard:
|
ch._save_refs()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
|
||||||
assert set(persisted.keys()) == {"conv-old"}
|
assert set(persisted.keys()) == {"conv-old"}
|
||||||
@@ -936,8 +934,7 @@ def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
with ch._refs_guard:
|
ch._save_refs()
|
||||||
ch._save_refs_locked()
|
|
||||||
|
|
||||||
assert set(ch._conversation_refs) == {"teams-good"}
|
assert set(ch._conversation_refs) == {"teams-good"}
|
||||||
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
|
||||||
|
|||||||
@@ -431,7 +431,6 @@ class SignalChannel(BaseChannel):
|
|||||||
session_key: str | None = None,
|
session_key: str | None = None,
|
||||||
is_dm: bool = False,
|
is_dm: bool = False,
|
||||||
authorization_id: str | None = None,
|
authorization_id: str | None = None,
|
||||||
require_existing_session: bool = False,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Handle an inbound message whose policy has already been checked.
|
"""Handle an inbound message whose policy has already been checked.
|
||||||
|
|
||||||
@@ -454,7 +453,6 @@ class SignalChannel(BaseChannel):
|
|||||||
media=media or [],
|
media=media or [],
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
session_key_override=session_key,
|
session_key_override=session_key,
|
||||||
require_existing_session=require_existing_session,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,7 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
|||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
|
||||||
OUTBOUND_META_AGENT_UI,
|
|
||||||
OutboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
GoalStateSyncEvent,
|
GoalStateSyncEvent,
|
||||||
GoalStatusEvent,
|
GoalStatusEvent,
|
||||||
@@ -33,6 +30,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
TurnEndEvent,
|
TurnEndEvent,
|
||||||
TurnModelUpdatedEvent,
|
TurnModelUpdatedEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
|
outbound_message_for_event,
|
||||||
)
|
)
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
@@ -51,7 +49,6 @@ from nanobot.security.workspace_access import (
|
|||||||
from nanobot.session.goal_state import goal_state_ws_blob
|
from nanobot.session.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.webui_turns import (
|
from nanobot.session.webui_turns import (
|
||||||
clear_websocket_turn_if_current,
|
clear_websocket_turn_if_current,
|
||||||
clear_websocket_turns,
|
|
||||||
mark_websocket_turn_transcript_persistence_failed,
|
mark_websocket_turn_transcript_persistence_failed,
|
||||||
register_queued_websocket_turn_if_idle,
|
register_queued_websocket_turn_if_idle,
|
||||||
websocket_turn_id,
|
websocket_turn_id,
|
||||||
@@ -84,8 +81,6 @@ from nanobot.webui.session_access import (
|
|||||||
WebuiSessionAccess,
|
WebuiSessionAccess,
|
||||||
session_mentions_runtime_context,
|
session_mentions_runtime_context,
|
||||||
)
|
)
|
||||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
|
||||||
from nanobot.webui.temporary_chats import TemporaryChatError
|
|
||||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||||
@@ -284,6 +279,21 @@ class WebSocketConfig(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_runtime_model_update(
|
||||||
|
bus: MessageBus,
|
||||||
|
model: str,
|
||||||
|
model_preset: str | None,
|
||||||
|
) -> None:
|
||||||
|
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
|
||||||
|
bus.outbound.put_nowait(
|
||||||
|
outbound_message_for_event(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="*",
|
||||||
|
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_inbound_payload(raw: str) -> str | None:
|
def _parse_inbound_payload(raw: str) -> str | None:
|
||||||
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
"""Parse a client frame into text; return None for empty or unrecognized content."""
|
||||||
text = raw.strip()
|
text = raw.strip()
|
||||||
@@ -373,13 +383,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._conn_default: dict[ServerConnection, str] = {}
|
self._conn_default: dict[ServerConnection, str] = {}
|
||||||
# Connections authenticated with a one-time token from /webui/bootstrap.
|
# Connections authenticated with a one-time token from /webui/bootstrap.
|
||||||
self._webui_connections: set[ServerConnection] = set()
|
self._webui_connections: set[ServerConnection] = set()
|
||||||
# Request/reply mutations aren't replayed across reconnects. Tasks may
|
|
||||||
# finish after a client-side deadline so an already-started mutation
|
|
||||||
# isn't ambiguously cancelled halfway through.
|
|
||||||
self._webui_request_tasks: dict[
|
|
||||||
tuple[ServerConnection, str],
|
|
||||||
asyncio.Task[None],
|
|
||||||
] = {}
|
|
||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._server_task: asyncio.Task[None] | None = None
|
self._server_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
@@ -390,7 +393,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._ingress = gateway.ingress
|
self._ingress = gateway.ingress
|
||||||
self._transcripts = gateway.transcripts
|
self._transcripts = gateway.transcripts
|
||||||
self._workspaces = gateway.workspaces
|
self._workspaces = gateway.workspaces
|
||||||
self._temporary_chats = gateway.temporary_chats
|
|
||||||
self._session_access = (
|
self._session_access = (
|
||||||
WebuiSessionAccess(gateway.session_manager)
|
WebuiSessionAccess(gateway.session_manager)
|
||||||
if gateway.session_manager is not None
|
if gateway.session_manager is not None
|
||||||
@@ -409,33 +411,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
self._subs.setdefault(chat_id, set()).add(connection)
|
self._subs.setdefault(chat_id, set()).add(connection)
|
||||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||||
|
|
||||||
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
|
|
||||||
chats = self._conn_chats.get(connection)
|
|
||||||
if chats is not None:
|
|
||||||
chats.discard(chat_id)
|
|
||||||
if not chats:
|
|
||||||
self._conn_chats.pop(connection, None)
|
|
||||||
subscribers = self._subs.get(chat_id)
|
|
||||||
if subscribers is not None:
|
|
||||||
subscribers.discard(connection)
|
|
||||||
if not subscribers:
|
|
||||||
self._subs.pop(chat_id, None)
|
|
||||||
|
|
||||||
def _clear_stream_buffers(self, chat_id: str) -> None:
|
|
||||||
for key in tuple(self._stream_text_buffers):
|
|
||||||
if key[0] == chat_id:
|
|
||||||
self._stream_text_buffers.pop(key, None)
|
|
||||||
|
|
||||||
async def _discard_connection_owned_chat(
|
|
||||||
self,
|
|
||||||
connection: ServerConnection,
|
|
||||||
chat_id: str,
|
|
||||||
) -> None:
|
|
||||||
await self._temporary_chats.discard(connection, chat_id)
|
|
||||||
self._detach(connection, chat_id)
|
|
||||||
clear_websocket_turns(chat_id)
|
|
||||||
self._clear_stream_buffers(chat_id)
|
|
||||||
|
|
||||||
async def send_webui_protocol_error(
|
async def send_webui_protocol_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -464,16 +439,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(fork_id)
|
await self._hydrate_after_subscribe(fork_id)
|
||||||
|
|
||||||
async def _cleanup_connection(self, connection: ServerConnection) -> None:
|
def _cleanup_connection(self, connection: ServerConnection) -> None:
|
||||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||||
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
chat_ids = self._conn_chats.pop(connection, set())
|
||||||
for cid in chat_ids:
|
for cid in chat_ids:
|
||||||
if self._temporary_chats.owns(connection, cid):
|
subs = self._subs.get(cid)
|
||||||
await self._discard_connection_owned_chat(connection, cid)
|
if subs is None:
|
||||||
else:
|
continue
|
||||||
self._detach(connection, cid)
|
subs.discard(connection)
|
||||||
for cid in self._temporary_chats.chat_ids_for_owner(connection):
|
if not subs:
|
||||||
await self._discard_connection_owned_chat(connection, cid)
|
self._subs.pop(cid, None)
|
||||||
self._conn_default.pop(connection, None)
|
self._conn_default.pop(connection, None)
|
||||||
self._webui_connections.discard(connection)
|
self._webui_connections.discard(connection)
|
||||||
|
|
||||||
@@ -526,7 +501,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("failed to send {} event: {}", event, e)
|
self.logger.warning("failed to send {} event: {}", event, e)
|
||||||
|
|
||||||
@@ -753,7 +728,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug("connection ended: {}", e)
|
self.logger.debug("connection ended: {}", e)
|
||||||
finally:
|
finally:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
|
|
||||||
# -- Inbound WebSocket envelopes ---------------------------------------
|
# -- Inbound WebSocket envelopes ---------------------------------------
|
||||||
|
|
||||||
@@ -765,9 +740,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||||
t = envelope.get("type")
|
t = envelope.get("type")
|
||||||
if t == "webui_request":
|
|
||||||
await self._start_webui_request(connection, envelope)
|
|
||||||
return
|
|
||||||
if t == "new_chat":
|
if t == "new_chat":
|
||||||
new_id = str(uuid.uuid4())
|
new_id = str(uuid.uuid4())
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
@@ -791,84 +763,23 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
await self._hydrate_after_subscribe(new_id)
|
await self._hydrate_after_subscribe(new_id)
|
||||||
return
|
return
|
||||||
if t == "new_temporary_chat":
|
|
||||||
try:
|
|
||||||
new_id = self._temporary_chats.create(
|
|
||||||
connection,
|
|
||||||
trusted_webui=connection in self._webui_connections,
|
|
||||||
)
|
|
||||||
except TemporaryChatError as exc:
|
|
||||||
await self._send_event(connection, "error", detail=exc.detail)
|
|
||||||
return
|
|
||||||
self._attach(connection, new_id)
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"attached",
|
|
||||||
chat_id=new_id,
|
|
||||||
temporary=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if t == "fork_chat":
|
if t == "fork_chat":
|
||||||
await handle_webui_fork_chat(self, connection, envelope)
|
await handle_webui_fork_chat(self, connection, envelope)
|
||||||
return
|
return
|
||||||
if t == "discard_temporary_chat":
|
|
||||||
cid = envelope.get("chat_id")
|
|
||||||
if not _is_valid_chat_id(cid):
|
|
||||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await self._discard_connection_owned_chat(connection, cid)
|
|
||||||
except TemporaryChatError as exc:
|
|
||||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
|
||||||
return
|
|
||||||
if t == "attach":
|
if t == "attach":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
self._temporary_chats.validate_attach(cid)
|
|
||||||
except TemporaryChatError as exc:
|
|
||||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
|
||||||
return
|
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
await self._send_event(connection, "attached", chat_id=cid)
|
await self._send_event(connection, "attached", chat_id=cid)
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
return
|
return
|
||||||
if t == "set_sidebar_state":
|
|
||||||
if connection not in self._webui_connections:
|
|
||||||
await self._send_event(connection, "error", detail="access_denied")
|
|
||||||
return
|
|
||||||
state = envelope.get("state")
|
|
||||||
if not isinstance(state, dict):
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="invalid_sidebar_state",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await asyncio.to_thread(
|
|
||||||
write_webui_sidebar_state,
|
|
||||||
cast(dict[str, Any], state),
|
|
||||||
)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="invalid_sidebar_state",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if t == "set_workspace_scope":
|
if t == "set_workspace_scope":
|
||||||
cid = envelope.get("chat_id")
|
cid = envelope.get("chat_id")
|
||||||
if not _is_valid_chat_id(cid):
|
if not _is_valid_chat_id(cid):
|
||||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
self._temporary_chats.validate_workspace_update(cid)
|
|
||||||
except TemporaryChatError as exc:
|
|
||||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
|
||||||
return
|
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: self._workspaces.scope_for_set_request(
|
lambda: self._workspaces.scope_for_set_request(
|
||||||
@@ -937,21 +848,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
|
||||||
temporary_policy = self._temporary_chats.message_policy(
|
|
||||||
connection,
|
|
||||||
cid,
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
except TemporaryChatError as exc:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail=exc.detail,
|
|
||||||
**rejection_fields,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
raw_media = envelope.get("media")
|
raw_media = envelope.get("media")
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
if raw_media is not None:
|
if raw_media is not None:
|
||||||
@@ -974,8 +870,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
**rejection_fields,
|
**rejection_fields,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if temporary_policy is not None:
|
|
||||||
self._temporary_chats.register_media(connection, cid, media_paths)
|
|
||||||
|
|
||||||
# Allow media-only turns (content may be empty when attachments are present).
|
# Allow media-only turns (content may be empty when attachments are present).
|
||||||
if not content.strip() and not media_paths:
|
if not content.strip() and not media_paths:
|
||||||
@@ -988,21 +882,16 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||||
self._attach(connection, cid)
|
self._attach(connection, cid)
|
||||||
if temporary_policy is None or temporary_policy.hydrate_transcript:
|
|
||||||
await self._hydrate_after_subscribe(cid)
|
await self._hydrate_after_subscribe(cid)
|
||||||
|
|
||||||
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||||
scope = await self._workspace_scope_or_error(
|
scope = await self._workspace_scope_or_error(
|
||||||
connection,
|
connection,
|
||||||
lambda: (
|
lambda: self._workspaces.scope_for_message(
|
||||||
temporary_policy.workspace_scope
|
|
||||||
if temporary_policy is not None
|
|
||||||
else self._workspaces.scope_for_message(
|
|
||||||
envelope,
|
envelope,
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||||
controls_available=self._workspace_controls_available(connection),
|
controls_available=self._workspace_controls_available(connection),
|
||||||
)
|
|
||||||
),
|
),
|
||||||
chat_id=cid,
|
chat_id=cid,
|
||||||
turn_id=turn_id,
|
turn_id=turn_id,
|
||||||
@@ -1055,13 +944,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||||
accepted = False
|
accepted = False
|
||||||
try:
|
try:
|
||||||
if (
|
if is_webui:
|
||||||
is_webui
|
|
||||||
and (
|
|
||||||
temporary_policy is None
|
|
||||||
or temporary_policy.persist_transcript
|
|
||||||
)
|
|
||||||
):
|
|
||||||
self._transcripts.append_user_message(
|
self._transcripts.append_user_message(
|
||||||
cid,
|
cid,
|
||||||
content,
|
content,
|
||||||
@@ -1090,16 +973,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
media=media_paths or None,
|
media=media_paths or None,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
is_dm=False,
|
is_dm=False,
|
||||||
session_key=(
|
|
||||||
temporary_policy.session_key
|
|
||||||
if temporary_policy is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
require_existing_session=(
|
|
||||||
temporary_policy.require_existing_session
|
|
||||||
if temporary_policy is not None
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
accepted = True
|
accepted = True
|
||||||
finally:
|
finally:
|
||||||
@@ -1115,152 +988,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||||
|
|
||||||
async def _start_webui_request(
|
|
||||||
self,
|
|
||||||
connection: ServerConnection,
|
|
||||||
envelope: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
request_id = envelope.get("request_id")
|
|
||||||
if not isinstance(request_id, str) or re.fullmatch(
|
|
||||||
r"[A-Za-z0-9._:-]{1,128}",
|
|
||||||
request_id,
|
|
||||||
) is None:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"error",
|
|
||||||
detail="invalid webui request_id",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if connection not in self._webui_connections:
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=403,
|
|
||||||
message="access_denied",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
action = envelope.get("action")
|
|
||||||
payload = envelope.get("payload")
|
|
||||||
if not isinstance(action, str) or re.fullmatch(
|
|
||||||
r"[a-z][a-z0-9_.]{0,127}",
|
|
||||||
action,
|
|
||||||
) is None:
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=400,
|
|
||||||
message="invalid WebUI mutation action",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=400,
|
|
||||||
message="WebUI mutation payload must be an object",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
key = (connection, request_id)
|
|
||||||
if key in self._webui_request_tasks:
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=409,
|
|
||||||
message="duplicate WebUI request_id",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
task = asyncio.create_task(
|
|
||||||
self._complete_webui_request(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
action,
|
|
||||||
cast(dict[str, Any], payload),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self._webui_request_tasks[key] = task
|
|
||||||
|
|
||||||
async def _complete_webui_request(
|
|
||||||
self,
|
|
||||||
connection: ServerConnection,
|
|
||||||
request_id: str,
|
|
||||||
action: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
response = await self._http_router.dispatch_webui_mutation(
|
|
||||||
connection,
|
|
||||||
action,
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
status = response.status_code
|
|
||||||
body = bytes(response.body).decode("utf-8", errors="replace").strip()
|
|
||||||
if 200 <= status < 300:
|
|
||||||
try:
|
|
||||||
result = json.loads(body)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=502,
|
|
||||||
message="WebUI mutation returned an invalid response",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=status,
|
|
||||||
message=body or response.reason_phrase,
|
|
||||||
)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("WebUI mutation '{}' failed", action)
|
|
||||||
await self._send_webui_response(
|
|
||||||
connection,
|
|
||||||
request_id,
|
|
||||||
status=500,
|
|
||||||
message="WebUI mutation failed",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
self._webui_request_tasks.pop((connection, request_id), None)
|
|
||||||
|
|
||||||
async def _send_webui_response(
|
|
||||||
self,
|
|
||||||
connection: ServerConnection,
|
|
||||||
request_id: str,
|
|
||||||
*,
|
|
||||||
result: Any = None,
|
|
||||||
status: int | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
if status is None:
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"webui_response",
|
|
||||||
request_id=request_id,
|
|
||||||
ok=True,
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self._send_event(
|
|
||||||
connection,
|
|
||||||
"webui_response",
|
|
||||||
request_id=request_id,
|
|
||||||
ok=False,
|
|
||||||
error={
|
|
||||||
"status": status,
|
|
||||||
"message": message or "WebUI mutation failed",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _workspace_scope_or_error(
|
async def _workspace_scope_or_error(
|
||||||
self,
|
self,
|
||||||
connection: ServerConnection,
|
connection: ServerConnection,
|
||||||
@@ -1301,18 +1028,11 @@ class WebSocketChannel(BaseChannel):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning("server task error during shutdown: {}", e)
|
self.logger.warning("server task error during shutdown: {}", e)
|
||||||
self._server_task = None
|
self._server_task = None
|
||||||
mutation_tasks = tuple(self._webui_request_tasks.values())
|
|
||||||
for task in mutation_tasks:
|
|
||||||
task.cancel()
|
|
||||||
if mutation_tasks:
|
|
||||||
await asyncio.gather(*mutation_tasks, return_exceptions=True)
|
|
||||||
self._webui_request_tasks.clear()
|
|
||||||
self._subs.clear()
|
self._subs.clear()
|
||||||
self._conn_chats.clear()
|
self._conn_chats.clear()
|
||||||
self._conn_default.clear()
|
self._conn_default.clear()
|
||||||
self._webui_connections.clear()
|
self._webui_connections.clear()
|
||||||
self._tokens.clear()
|
self._tokens.clear()
|
||||||
self._temporary_chats.close()
|
|
||||||
|
|
||||||
async def _safe_send_to(
|
async def _safe_send_to(
|
||||||
self,
|
self,
|
||||||
@@ -1325,7 +1045,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
try:
|
try:
|
||||||
await connection.send(raw)
|
await connection.send(raw)
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
await self._cleanup_connection(connection)
|
self._cleanup_connection(connection)
|
||||||
self.logger.warning("connection gone{}", label)
|
self.logger.warning("connection gone{}", label)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("send failed{}", label)
|
self.logger.exception("send failed{}", label)
|
||||||
@@ -1342,8 +1062,6 @@ class WebSocketChannel(BaseChannel):
|
|||||||
transcript_overrides: dict[str, Any] | None = None,
|
transcript_overrides: dict[str, Any] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
|
||||||
return True
|
|
||||||
persisted = self._transcripts.prepare_and_append(
|
persisted = self._transcripts.prepare_and_append(
|
||||||
chat_id,
|
chat_id,
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -3,23 +3,17 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
import pytest
|
||||||
import websockets
|
import websockets
|
||||||
from websockets.datastructures import Headers
|
|
||||||
from websockets.exceptions import ConnectionClosed
|
from websockets.exceptions import ConnectionClosed
|
||||||
from websockets.frames import Close
|
from websockets.frames import Close
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
from nanobot.bus.events import (
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
OUTBOUND_META_AGENT_UI,
|
OUTBOUND_META_AGENT_UI,
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
OutboundMessage,
|
OutboundMessage,
|
||||||
)
|
)
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@@ -38,20 +32,14 @@ from nanobot.channels.websocket.runtime import (
|
|||||||
_is_valid_chat_id,
|
_is_valid_chat_id,
|
||||||
_parse_envelope,
|
_parse_envelope,
|
||||||
_parse_inbound_payload,
|
_parse_inbound_payload,
|
||||||
|
publish_runtime_model_update,
|
||||||
)
|
)
|
||||||
from nanobot.config.loader import load_config, save_config
|
from nanobot.config.loader import load_config, save_config
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
||||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
|
||||||
from nanobot.session import webui_turns as wth
|
from nanobot.session import webui_turns as wth
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||||
from nanobot.webui.http_utils import (
|
|
||||||
http_error as _http_error,
|
|
||||||
)
|
|
||||||
from nanobot.webui.http_utils import (
|
|
||||||
http_json_response as _http_json_response,
|
|
||||||
)
|
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
issue_route_secret_matches as _issue_route_secret_matches,
|
issue_route_secret_matches as _issue_route_secret_matches,
|
||||||
)
|
)
|
||||||
@@ -129,38 +117,6 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _webui_mutate(
|
|
||||||
client: Any,
|
|
||||||
action: str,
|
|
||||||
payload: dict[str, Any] | None = None,
|
|
||||||
) -> httpx.Response:
|
|
||||||
request_id = f"test-{uuid.uuid4().hex}"
|
|
||||||
await client.send(json.dumps({
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": request_id,
|
|
||||||
"action": action,
|
|
||||||
"payload": payload or {},
|
|
||||||
}))
|
|
||||||
while True:
|
|
||||||
envelope = json.loads(await asyncio.wait_for(client.recv(), timeout=5))
|
|
||||||
if envelope.get("event") != "webui_response":
|
|
||||||
continue
|
|
||||||
if envelope.get("request_id") != request_id:
|
|
||||||
continue
|
|
||||||
if envelope.get("ok") is True:
|
|
||||||
status = 200
|
|
||||||
body = envelope.get("result")
|
|
||||||
else:
|
|
||||||
error = envelope.get("error") or {}
|
|
||||||
status = int(error.get("status") or 500)
|
|
||||||
body = {"error": str(error.get("message") or "WebUI mutation failed")}
|
|
||||||
return httpx.Response(
|
|
||||||
status,
|
|
||||||
json=body,
|
|
||||||
request=httpx.Request("WS", "http://nanobot.local/webui-mutation"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
async def test_stop_treats_cancelled_server_task_as_shutdown() -> None:
|
||||||
channel = _ch(MessageBus())
|
channel = _ch(MessageBus())
|
||||||
@@ -237,302 +193,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
|||||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||||
|
|
||||||
|
|
||||||
async def _new_temporary_chat(
|
|
||||||
channel: WebSocketChannel,
|
|
||||||
connection: AsyncMock,
|
|
||||||
) -> str:
|
|
||||||
channel._webui_connections.add(connection)
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{"type": "new_temporary_chat"},
|
|
||||||
)
|
|
||||||
payload = json.loads(connection.send.await_args.args[0])
|
|
||||||
assert payload["event"] == "attached"
|
|
||||||
assert payload["temporary"] is True
|
|
||||||
connection.send.reset_mock()
|
|
||||||
return payload["chat_id"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
selected_project = tmp_path / "selected-project"
|
|
||||||
selected_project.mkdir()
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=sessions,
|
|
||||||
workspace_path=tmp_path,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
connection.remote_address = ("127.0.0.1", 5000)
|
|
||||||
chat_id = await _new_temporary_chat(channel, connection)
|
|
||||||
upload = tmp_path / "temporary-upload.txt"
|
|
||||||
upload.write_text("private attachment", encoding="utf-8")
|
|
||||||
channel.gateway.media.store_inbound_attachments = MagicMock(
|
|
||||||
return_value=([str(upload)], None),
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"content": "read this",
|
|
||||||
"media": [{"data_url": "data:text/plain;base64,cHJpdmF0ZQ=="}],
|
|
||||||
"cli_apps": [{"name": "drawio"}],
|
|
||||||
"workspace_scope": {
|
|
||||||
"project_path": str(selected_project),
|
|
||||||
"access_mode": "full",
|
|
||||||
},
|
|
||||||
"turn_id": "turn-1",
|
|
||||||
"webui": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
inbound = bus.publish_inbound.await_args_list[0].args[0]
|
|
||||||
assert inbound.session_key == f"websocket:{chat_id}"
|
|
||||||
assert inbound.session_key_override == f"websocket:{chat_id}"
|
|
||||||
assert inbound.require_existing_session is True
|
|
||||||
assert inbound.metadata["cli_apps"] == [{"name": "drawio"}]
|
|
||||||
assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
|
|
||||||
"project_path": str(tmp_path.resolve()),
|
|
||||||
"access_mode": "restricted",
|
|
||||||
}
|
|
||||||
session = sessions.get_cached(inbound.session_key)
|
|
||||||
assert session is not None
|
|
||||||
assert session.policy.persist is False
|
|
||||||
assert upload.exists()
|
|
||||||
assert read_transcript_lines(inbound.session_key) == []
|
|
||||||
assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [
|
|
||||||
"message_accepted",
|
|
||||||
]
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
|
||||||
)
|
|
||||||
|
|
||||||
control = bus.publish_inbound.await_args_list[1].args[0]
|
|
||||||
assert bus.publish_inbound.await_count == 2
|
|
||||||
assert control.session_key == inbound.session_key
|
|
||||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD
|
|
||||||
)
|
|
||||||
assert sessions.get_cached(inbound.session_key) is None
|
|
||||||
assert chat_id not in channel._subs
|
|
||||||
assert chat_id not in channel._conn_chats.get(connection, set())
|
|
||||||
assert not upload.exists()
|
|
||||||
assert read_transcript_lines(inbound.session_key) == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"])
|
|
||||||
async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
connection.remote_address = ("127.0.0.1", 5000)
|
|
||||||
chat_id = await _new_temporary_chat(channel, connection)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(connection, "webui-client", {
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"content": content,
|
|
||||||
"webui": True,
|
|
||||||
})
|
|
||||||
|
|
||||||
assert bus.publish_inbound.await_count == 0
|
|
||||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
|
||||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
|
||||||
"temporary_chat_command_rejected"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(
|
|
||||||
bus,
|
|
||||||
session_manager=sessions,
|
|
||||||
workspace_path=tmp_path,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
chat_id = await _new_temporary_chat(channel, connection)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"content": "hello",
|
|
||||||
"webui": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await channel._cleanup_connection(connection)
|
|
||||||
|
|
||||||
session_key = f"websocket:{chat_id}"
|
|
||||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
|
||||||
assert control.session_key == session_key
|
|
||||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD
|
|
||||||
)
|
|
||||||
assert sessions.get_cached(session_key) is None
|
|
||||||
assert chat_id not in channel._subs
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"generic-websocket-client",
|
|
||||||
{"type": "new_temporary_chat"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied"
|
|
||||||
assert sessions.list_sessions() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
owner = AsyncMock()
|
|
||||||
other = AsyncMock()
|
|
||||||
channel._webui_connections.add(other)
|
|
||||||
chat_id = await _new_temporary_chat(channel, owner)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
other,
|
|
||||||
"other-webui-client",
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"content": "claim it",
|
|
||||||
"webui": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert json.loads(other.send.await_args.args[0])["detail"] == (
|
|
||||||
"temporary_chat_unavailable"
|
|
||||||
)
|
|
||||||
assert bus.publish_inbound.await_count == 0
|
|
||||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
chat_id = await _new_temporary_chat(channel, connection)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "set_workspace_scope",
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"workspace_scope": {
|
|
||||||
"project_path": str(tmp_path),
|
|
||||||
"access_mode": "full",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.loads(connection.send.await_args.args[0])
|
|
||||||
assert payload["detail"] == "temporary_chat_workspace_rejected"
|
|
||||||
session = sessions.get_cached(f"websocket:{chat_id}")
|
|
||||||
assert session is not None
|
|
||||||
assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata
|
|
||||||
assert sessions.list_sessions() == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
channel._webui_connections.add(connection)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "message",
|
|
||||||
"chat_id": "temporary-looking-but-persistent",
|
|
||||||
"content": "/goal ordinary chat",
|
|
||||||
"webui": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
inbound = bus.publish_inbound.await_args.args[0]
|
|
||||||
assert inbound.require_existing_session is False
|
|
||||||
assert inbound.session_key_override is None
|
|
||||||
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
|
||||||
assert session is not None
|
|
||||||
assert session.policy.persist is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None:
|
|
||||||
sessions = SessionManager(tmp_path)
|
|
||||||
channel = WebSocketChannel(
|
|
||||||
{"enabled": True, "allowFrom": ["*"]},
|
|
||||||
bus,
|
|
||||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
|
||||||
)
|
|
||||||
connection = AsyncMock()
|
|
||||||
channel._attach(connection, "ordinary-chat")
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
connection,
|
|
||||||
"webui-client",
|
|
||||||
{"type": "discard_temporary_chat", "chat_id": "ordinary-chat"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
|
||||||
"temporary_chat_unavailable"
|
|
||||||
)
|
|
||||||
assert connection in channel._subs["ordinary-chat"]
|
|
||||||
assert "ordinary-chat" in channel._conn_chats[connection]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||||
class Conn:
|
class Conn:
|
||||||
@@ -899,136 +559,6 @@ def test_only_bootstrap_tokens_mark_webui_connections(bus: MagicMock) -> None:
|
|||||||
assert client_connection not in channel._webui_connections
|
assert client_connection not in channel._webui_connections
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_authenticated_webui_request_returns_correlated_success(bus: MagicMock) -> None:
|
|
||||||
channel = _ch(bus)
|
|
||||||
conn = AsyncMock()
|
|
||||||
channel._webui_connections.add(conn)
|
|
||||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
|
||||||
return_value=_http_json_response({"saved": True})
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
conn,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": "request-1",
|
|
||||||
"action": "settings.provider.update",
|
|
||||||
"payload": {"provider": "openrouter", "apiKey": "secret"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
|
||||||
|
|
||||||
channel.gateway.http.dispatch_webui_mutation.assert_awaited_once_with(
|
|
||||||
conn,
|
|
||||||
"settings.provider.update",
|
|
||||||
{"provider": "openrouter", "apiKey": "secret"},
|
|
||||||
)
|
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": "request-1",
|
|
||||||
"ok": True,
|
|
||||||
"result": {"saved": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_request_returns_correlated_route_error(bus: MagicMock) -> None:
|
|
||||||
channel = _ch(bus)
|
|
||||||
conn = AsyncMock()
|
|
||||||
channel._webui_connections.add(conn)
|
|
||||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock(
|
|
||||||
return_value=_http_error(400, "invalid settings payload")
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
conn,
|
|
||||||
"webui-client",
|
|
||||||
{
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": "request-2",
|
|
||||||
"action": "settings.agent.update",
|
|
||||||
"payload": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
|
||||||
|
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": "request-2",
|
|
||||||
"ok": False,
|
|
||||||
"error": {"status": 400, "message": "invalid settings payload"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_request_requires_bootstrap_authenticated_connection(
|
|
||||||
bus: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
channel = _ch(bus)
|
|
||||||
conn = AsyncMock()
|
|
||||||
channel.gateway.http.dispatch_webui_mutation = AsyncMock()
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(
|
|
||||||
conn,
|
|
||||||
"static-token-client",
|
|
||||||
{
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": "request-3",
|
|
||||||
"action": "settings.agent.update",
|
|
||||||
"payload": {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
channel.gateway.http.dispatch_webui_mutation.assert_not_awaited()
|
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": "request-3",
|
|
||||||
"ok": False,
|
|
||||||
"error": {"status": 403, "message": "access_denied"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_webui_persists_sidebar_state_larger_than_http_request_line(
|
|
||||||
bus: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
|
||||||
channel = _ch(bus)
|
|
||||||
conn = AsyncMock()
|
|
||||||
conn.request = SimpleNamespace(headers=Headers())
|
|
||||||
channel._webui_connections.add(conn)
|
|
||||||
session_order = [f"websocket:{index:04d}-{'x' * 48}" for index in range(160)]
|
|
||||||
request_id = "sidebar-large-state"
|
|
||||||
envelope = {
|
|
||||||
"type": "webui_request",
|
|
||||||
"request_id": request_id,
|
|
||||||
"action": "sidebar.update",
|
|
||||||
"payload": {"state": {
|
|
||||||
"session_order": session_order,
|
|
||||||
"view": {"sort": "manual"},
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
assert len(json.dumps(envelope).encode()) > 8_192
|
|
||||||
|
|
||||||
await channel._dispatch_envelope(conn, "webui-client", envelope)
|
|
||||||
await asyncio.gather(*tuple(channel._webui_request_tasks.values()))
|
|
||||||
|
|
||||||
saved = json.loads((tmp_path / "webui" / "sidebar-state.json").read_text(encoding="utf-8"))
|
|
||||||
assert saved["session_order"] == session_order
|
|
||||||
assert saved["view"]["sort"] == "manual"
|
|
||||||
assert json.loads(conn.send.await_args.args[0]) == {
|
|
||||||
"event": "webui_response",
|
|
||||||
"request_id": request_id,
|
|
||||||
"ok": True,
|
|
||||||
"result": saved,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
async def test_client_cannot_self_assert_webui_quote_context(bus: MagicMock) -> None:
|
||||||
channel = _ch(bus)
|
channel = _ch(bus)
|
||||||
@@ -1547,14 +1077,8 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
|
|||||||
mock_ws = AsyncMock()
|
mock_ws = AsyncMock()
|
||||||
channel._attach(mock_ws, "chat-1")
|
channel._attach(mock_ws, "chat-1")
|
||||||
|
|
||||||
await channel.send(
|
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
|
||||||
OutboundMessage(
|
await channel.send(bus.outbound.get_nowait())
|
||||||
channel="websocket",
|
|
||||||
chat_id="*",
|
|
||||||
content="",
|
|
||||||
event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = json.loads(mock_ws.send.call_args[0][0])
|
payload = json.loads(mock_ws.send.call_args[0][0])
|
||||||
assert payload["event"] == "runtime_model_updated"
|
assert payload["event"] == "runtime_model_updated"
|
||||||
@@ -1589,6 +1113,26 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
|
|||||||
chat_two.send.assert_not_awaited()
|
chat_two.send.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
|
||||||
|
bus = MessageBus()
|
||||||
|
|
||||||
|
publish_runtime_model_update(
|
||||||
|
bus,
|
||||||
|
"openai/gpt-4.1",
|
||||||
|
"fast",
|
||||||
|
)
|
||||||
|
|
||||||
|
event = bus.outbound.get_nowait()
|
||||||
|
assert event.channel == "websocket"
|
||||||
|
assert event.chat_id == "*"
|
||||||
|
assert event.content == ""
|
||||||
|
assert event.metadata == {}
|
||||||
|
assert isinstance(event.event, RuntimeModelUpdatedEvent)
|
||||||
|
assert event.event.model == "openai/gpt-4.1"
|
||||||
|
assert event.event.model_preset == "fast"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
@@ -3031,15 +2575,7 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
webui_client = None
|
|
||||||
try:
|
try:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
|
||||||
webui_client = await websockets.connect(
|
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=settings-test"
|
|
||||||
)
|
|
||||||
ready = json.loads(await asyncio.wait_for(webui_client.recv(), timeout=5))
|
|
||||||
assert ready["event"] == "ready"
|
|
||||||
|
|
||||||
settings = await _http_get(
|
settings = await _http_get(
|
||||||
f"http://127.0.0.1:{port}/api/settings",
|
f"http://127.0.0.1:{port}/api/settings",
|
||||||
headers={"Authorization": "Bearer tok"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
@@ -3123,14 +2659,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert unknown_api.status_code == 404
|
assert unknown_api.status_code == 404
|
||||||
assert "<!doctype html>" not in unknown_api.text.lower()
|
assert "<!doctype html>" not in unknown_api.text.lower()
|
||||||
|
|
||||||
provider_updated = await _webui_mutate(
|
provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-test&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"apiKey": "sk-or-test",
|
|
||||||
"apiBase": "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert provider_updated.status_code == 200
|
assert provider_updated.status_code == 200
|
||||||
provider_body = provider_updated.json()
|
provider_body = provider_updated.json()
|
||||||
@@ -3140,9 +2673,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert provider_body["image_generation"]["provider_configured"] is True
|
assert provider_body["image_generation"]["provider_configured"] is True
|
||||||
assert "sk-or-test" not in provider_updated.text
|
assert "sk-or-test" not in provider_updated.text
|
||||||
|
|
||||||
custom_provider_created = await _webui_mutate(
|
custom_provider_created = await _http_get(
|
||||||
webui_client,
|
f"http://127.0.0.1:{port}/api/settings/provider/create",
|
||||||
"settings.provider.create",
|
headers={
|
||||||
|
"Authorization": "Bearer tok",
|
||||||
|
"X-Nanobot-Provider-Values": json.dumps(
|
||||||
{
|
{
|
||||||
"name": "Company Gateway",
|
"name": "Company Gateway",
|
||||||
"apiBase": "https://gateway.example/v1",
|
"apiBase": "https://gateway.example/v1",
|
||||||
@@ -3152,6 +2687,8 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
"extraQuery": json.dumps({"api-version": "2026-01-01"}),
|
||||||
"proxy": "http://127.0.0.1:7890",
|
"proxy": "http://127.0.0.1:7890",
|
||||||
"thinkingStyle": "enable_thinking",
|
"thinkingStyle": "enable_thinking",
|
||||||
|
}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert custom_provider_created.status_code == 200
|
assert custom_provider_created.status_code == 200
|
||||||
@@ -3166,10 +2703,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert "sk-company" not in custom_provider_created.text
|
assert "sk-company" not in custom_provider_created.text
|
||||||
|
|
||||||
local_provider_updated = await _webui_mutate(
|
local_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=atomic_chat"
|
||||||
{"provider": "atomic_chat", "apiBase": "http://localhost:1337/v1"},
|
"&api_base=http%3A%2F%2Flocalhost%3A1337%2Fv1",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert local_provider_updated.status_code == 200
|
assert local_provider_updated.status_code == 200
|
||||||
local_provider_body = local_provider_updated.json()
|
local_provider_body = local_provider_updated.json()
|
||||||
@@ -3179,44 +2717,38 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert local_provider_rows["atomic_chat"]["configured"] is True
|
assert local_provider_rows["atomic_chat"]["configured"] is True
|
||||||
assert "localhost:1337" in local_provider_updated.text
|
assert "localhost:1337" in local_provider_updated.text
|
||||||
|
|
||||||
updated = await _webui_mutate(
|
updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model=atomic_chat/test"
|
||||||
{
|
"&provider=atomic_chat&timezone=Asia%2FShanghai"
|
||||||
"model": "atomic_chat/test",
|
"&bot_name=Nano&bot_icon=N&tool_hint_max_length=120",
|
||||||
"provider": "atomic_chat",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"timezone": "Asia/Shanghai",
|
|
||||||
"tool_hint_max_length": 120,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert updated.status_code == 200
|
assert updated.status_code == 200
|
||||||
updated_body = updated.json()
|
updated_body = updated.json()
|
||||||
assert updated_body["requires_restart"] is True
|
assert updated_body["requires_restart"] is True
|
||||||
assert updated_body["restart_required_sections"] == ["runtime"]
|
assert updated_body["restart_required_sections"] == ["runtime"]
|
||||||
|
|
||||||
preset_updated = await _webui_mutate(
|
preset_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=deep",
|
||||||
{"model_preset": "deep"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert preset_updated.status_code == 200
|
assert preset_updated.status_code == 200
|
||||||
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
assert preset_updated.json()["agent"]["model"] == "anthropic/claude-opus-4-5"
|
||||||
|
|
||||||
bad_preset = await _webui_mutate(
|
bad_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.agent.update",
|
f"{port}/api/settings/update?model_preset=missing",
|
||||||
{"model_preset": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_preset.status_code == 400
|
assert bad_preset.status_code == 400
|
||||||
|
|
||||||
created_preset = await _webui_mutate(
|
created_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-4.1-mini",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert created_preset.status_code == 200
|
assert created_preset.status_code == 200
|
||||||
created_body = created_preset.json()
|
created_body = created_preset.json()
|
||||||
@@ -3230,15 +2762,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
assert created_presets["fast-writing"]["label"] == "Fast writing"
|
||||||
assert created_presets["fast-writing"]["provider"] == "openai"
|
assert created_presets["fast-writing"]["provider"] == "openai"
|
||||||
|
|
||||||
updated_preset = await _webui_mutate(
|
updated_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.update",
|
f"{port}/api/settings/model-configurations/update"
|
||||||
{
|
"?name=fast-writing&label=Codex&provider=openai&model=openai%2Fgpt-5.5",
|
||||||
"name": "fast-writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"label": "Codex",
|
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-5.5",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert updated_preset.status_code == 200
|
assert updated_preset.status_code == 200
|
||||||
updated_preset_body = updated_preset.json()
|
updated_preset_body = updated_preset.json()
|
||||||
@@ -3249,10 +2777,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
}
|
}
|
||||||
assert updated_presets["fast-writing"]["label"] == "Codex"
|
assert updated_presets["fast-writing"]["label"] == "Codex"
|
||||||
|
|
||||||
call_order_updated = await _webui_mutate(
|
call_order_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_call_order.update",
|
f"{port}/api/settings/model-call-order/update"
|
||||||
{"order": ["fast-writing", "deep"]},
|
"?order=%5B%22fast-writing%22%2C%22deep%22%5D",
|
||||||
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert call_order_updated.status_code == 200
|
assert call_order_updated.status_code == 200
|
||||||
call_order_body = call_order_updated.json()
|
call_order_body = call_order_updated.json()
|
||||||
@@ -3260,27 +2789,20 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
assert call_order_body["agent"]["model"] == "openai/gpt-5.5"
|
||||||
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
assert call_order_body["model_call_order"] == ["fast-writing", "deep"]
|
||||||
|
|
||||||
duplicate_preset = await _webui_mutate(
|
duplicate_preset = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.model_configuration.create",
|
f"{port}/api/settings/model-configurations/create"
|
||||||
{
|
"?label=Fast%20writing&provider=openai&model=openai%2Fgpt-4.1-mini",
|
||||||
"label": "Fast writing",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"provider": "openai",
|
|
||||||
"model": "openai/gpt-4.1-mini",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert duplicate_preset.status_code == 409
|
assert duplicate_preset.status_code == 409
|
||||||
|
|
||||||
search_updated = await _webui_mutate(
|
search_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=searxng"
|
||||||
{
|
"&base_url=https%3A%2F%2Fsearch.example.com"
|
||||||
"provider": "searxng",
|
"&max_results=8&timeout=45&use_jina_reader=false",
|
||||||
"base_url": "https://search.example.com",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"max_results": 8,
|
|
||||||
"timeout": 45,
|
|
||||||
"use_jina_reader": False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert search_updated.status_code == 200
|
assert search_updated.status_code == 200
|
||||||
search_body = search_updated.json()
|
search_body = search_updated.json()
|
||||||
@@ -3292,13 +2814,10 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert search_body["web_search"]["max_results"] == 8
|
assert search_body["web_search"]["max_results"] == 8
|
||||||
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
assert search_body["web"]["fetch"]["use_jina_reader"] is False
|
||||||
|
|
||||||
network_safety_updated = await _webui_mutate(
|
network_safety_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.network_safety.update",
|
f"{port}/api/settings/network-safety/update?webui_allow_local_service_access=false&webui_default_access_mode=full",
|
||||||
{
|
headers={"Authorization": "Bearer tok"},
|
||||||
"webui_allow_local_service_access": False,
|
|
||||||
"webui_default_access_mode": "full",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert network_safety_updated.status_code == 200
|
assert network_safety_updated.status_code == 200
|
||||||
network_safety_body = network_safety_updated.json()
|
network_safety_body = network_safety_updated.json()
|
||||||
@@ -3308,17 +2827,13 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
assert network_safety_body["advanced"]["webui_default_access_mode"] == "full"
|
||||||
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
assert network_safety_body["advanced"]["private_service_protection_enabled"] is True
|
||||||
|
|
||||||
image_updated = await _webui_mutate(
|
image_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?enabled=true"
|
||||||
{
|
"&provider=openrouter&model=openai%2Fgpt-image-1"
|
||||||
"enabled": True,
|
"&default_aspect_ratio=16%3A9&default_image_size=2K"
|
||||||
"provider": "openrouter",
|
"&max_images_per_turn=3",
|
||||||
"model": "openai/gpt-image-1",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"default_aspect_ratio": "16:9",
|
|
||||||
"default_image_size": "2K",
|
|
||||||
"max_images_per_turn": 3,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert image_updated.status_code == 200
|
assert image_updated.status_code == 200
|
||||||
image_body = image_updated.json()
|
image_body = image_updated.json()
|
||||||
@@ -3330,14 +2845,11 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert image_body["image_generation"]["default_image_size"] == "2K"
|
assert image_body["image_generation"]["default_image_size"] == "2K"
|
||||||
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
assert image_body["image_generation"]["max_images_per_turn"] == 3
|
||||||
|
|
||||||
image_provider_updated = await _webui_mutate(
|
image_provider_updated = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.provider.update",
|
f"{port}/api/settings/provider/update?provider=openrouter"
|
||||||
{
|
"&api_key=sk-or-next&api_base=https%3A%2F%2Fopenrouter.ai%2Fapi%2Fv1",
|
||||||
"provider": "openrouter",
|
headers={"Authorization": "Bearer tok"},
|
||||||
"apiKey": "sk-or-next",
|
|
||||||
"apiBase": "https://openrouter.ai/api/v1",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert image_provider_updated.status_code == 200
|
assert image_provider_updated.status_code == 200
|
||||||
assert image_provider_updated.json()["requires_restart"] is True
|
assert image_provider_updated.json()["requires_restart"] is True
|
||||||
@@ -3345,17 +2857,17 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert "sk-or-next" not in image_provider_updated.text
|
assert "sk-or-next" not in image_provider_updated.text
|
||||||
assert image_reload.await_count == 2
|
assert image_reload.await_count == 2
|
||||||
|
|
||||||
bad_web = await _webui_mutate(
|
bad_web = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.web_search.update",
|
f"{port}/api/settings/web-search/update?provider=duckduckgo&max_results=99",
|
||||||
{"provider": "duckduckgo", "max_results": 99},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_web.status_code == 400
|
assert bad_web.status_code == 400
|
||||||
|
|
||||||
bad_image = await _webui_mutate(
|
bad_image = await _http_get(
|
||||||
webui_client,
|
"http://127.0.0.1:"
|
||||||
"settings.image_generation.update",
|
f"{port}/api/settings/image-generation/update?provider=missing",
|
||||||
{"provider": "missing"},
|
headers={"Authorization": "Bearer tok"},
|
||||||
)
|
)
|
||||||
assert bad_image.status_code == 400
|
assert bad_image.status_code == 400
|
||||||
|
|
||||||
@@ -3392,8 +2904,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
|
|||||||
assert saved.tools.image_generation.default_image_size == "2K"
|
assert saved.tools.image_generation.default_image_size == "2K"
|
||||||
assert saved.tools.image_generation.max_images_per_turn == 3
|
assert saved.tools.image_generation.max_images_per_turn == 3
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3426,17 +2936,11 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
webui_client = None
|
|
||||||
try:
|
try:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-reload-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
headers={"Authorization": "Bearer tok"},
|
||||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
|
||||||
response = await _webui_mutate(
|
|
||||||
webui_client,
|
|
||||||
"settings.image_generation.update",
|
|
||||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -3444,8 +2948,6 @@ async def test_image_settings_hot_reload_without_restart(
|
|||||||
assert response.json()["restart_required_sections"] == []
|
assert response.json()["restart_required_sections"] == []
|
||||||
image_reload.assert_awaited_once_with(bus)
|
image_reload.assert_awaited_once_with(bus)
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
@@ -3477,25 +2979,17 @@ async def test_image_settings_fall_back_to_restart_when_hot_reload_fails(
|
|||||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
await asyncio.sleep(0.3)
|
await asyncio.sleep(0.3)
|
||||||
webui_client = None
|
|
||||||
try:
|
try:
|
||||||
webui_token = channel.gateway.tokens.issue_token(300, audience="webui")
|
response = await _http_get(
|
||||||
webui_client = await websockets.connect(
|
f"http://127.0.0.1:{port}/api/settings/image-generation/update"
|
||||||
f"ws://127.0.0.1:{port}/ws?token={webui_token}&client_id=image-fallback-test"
|
"?enabled=true&provider=openrouter&model=openai%2Fgpt-image-1",
|
||||||
)
|
headers={"Authorization": "Bearer tok"},
|
||||||
assert json.loads(await webui_client.recv())["event"] == "ready"
|
|
||||||
response = await _webui_mutate(
|
|
||||||
webui_client,
|
|
||||||
"settings.image_generation.update",
|
|
||||||
{"enabled": True, "provider": "openrouter", "model": "openai/gpt-image-1"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["requires_restart"] is True
|
assert response.json()["requires_restart"] is True
|
||||||
assert response.json()["restart_required_sections"] == ["image"]
|
assert response.json()["restart_required_sections"] == ["image"]
|
||||||
finally:
|
finally:
|
||||||
if webui_client is not None:
|
|
||||||
await webui_client.close()
|
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,11 @@
|
|||||||
"""Tests for the signed ``/api/media/<sig>/<payload>`` route and WebUI replay.
|
"""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 local media rendered by the WebUI. These tests
|
The route is the return path for images attached to persisted user turns:
|
||||||
cover URL signing and serving end-to-end plus the adversarial edges (bad
|
:meth:`WebSocketChannel.gateway.media.sign_media_path` mints URLs during session reads,
|
||||||
signatures, ``..`` traversal, non-existent files, non-image types).
|
and :meth:`GatewayHTTPHandler._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
|
from __future__ import annotations
|
||||||
@@ -17,12 +20,11 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
|
from nanobot.channels.websocket.runtime import WebSocketChannel, WebSocketConfig
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.webui.gateway_services import build_gateway_services
|
from nanobot.webui.gateway_services import build_gateway_services
|
||||||
from nanobot.webui.media_api import (
|
from nanobot.webui.media_api import (
|
||||||
b64url_decode,
|
b64url_decode,
|
||||||
b64url_encode,
|
b64url_encode,
|
||||||
sign_media_path,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from .ws_test_client import InProcessHttpChannel
|
from .ws_test_client import InProcessHttpChannel
|
||||||
@@ -85,16 +87,8 @@ def _fake_media_dir(root: Path):
|
|||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
def _sign_media_path(channel: WebSocketChannel, path: Path) -> str | None:
|
|
||||||
return sign_media_path(
|
|
||||||
path,
|
|
||||||
secret=channel.gateway.media.secret,
|
|
||||||
media_dir=channel.gateway.media._media_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# media_api.sign_media_path: the URL minter
|
# gateway.media.sign_media_path: the URL minter
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -114,10 +108,10 @@ def test_sign_media_path_rejects_paths_outside_media_root(
|
|||||||
media.mkdir()
|
media.mkdir()
|
||||||
channel = _ch(bus, port=0)
|
channel = _ch(bus, port=0)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
assert _sign_media_path(channel, outside) is None
|
assert channel.gateway.media.sign_media_path(outside) is None
|
||||||
# Traversal via the media root is also rejected — the resolve() step
|
# Traversal via the media root is also rejected — the resolve() step
|
||||||
# normalises ``..`` out before the relative_to check.
|
# normalises ``..`` out before the relative_to check.
|
||||||
assert _sign_media_path(channel, media / ".." / "secrets" / "cred.txt") is None
|
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
|
||||||
|
|
||||||
|
|
||||||
def test_sign_media_path_round_trips_via_hmac(
|
def test_sign_media_path_round_trips_via_hmac(
|
||||||
@@ -129,7 +123,7 @@ def test_sign_media_path_round_trips_via_hmac(
|
|||||||
(media / "a.png").write_bytes(_PNG_BYTES)
|
(media / "a.png").write_bytes(_PNG_BYTES)
|
||||||
channel = _ch(bus, port=0)
|
channel = _ch(bus, port=0)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url = _sign_media_path(channel, media / "a.png")
|
url = channel.gateway.media.sign_media_path(media / "a.png")
|
||||||
assert url is not None
|
assert url is not None
|
||||||
assert url.startswith("/api/media/")
|
assert url.startswith("/api/media/")
|
||||||
sig, payload = url[len("/api/media/"):].split("/", 1)
|
sig, payload = url[len("/api/media/"):].split("/", 1)
|
||||||
@@ -244,7 +238,7 @@ async def test_media_route_serves_signed_file(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29920)
|
channel = _ch(bus, port=29920)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -276,7 +270,7 @@ async def test_media_route_serves_video_byte_ranges(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29927)
|
channel = _ch(bus, port=29927)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -307,7 +301,7 @@ async def test_media_route_serves_suffix_video_byte_ranges(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29928)
|
channel = _ch(bus, port=29928)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -335,7 +329,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29929)
|
channel = _ch(bus, port=29929)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -367,7 +361,7 @@ async def test_media_route_rejects_bad_signature(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29921)
|
channel = _ch(bus, port=29921)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
good = _sign_media_path(channel, media / "f.png")
|
good = channel.gateway.media.sign_media_path(media / "f.png")
|
||||||
assert good is not None
|
assert good is not None
|
||||||
_, payload = good[len("/api/media/"):].split("/", 1)
|
_, payload = good[len("/api/media/"):].split("/", 1)
|
||||||
# Forge a sig with a *different* secret.
|
# Forge a sig with a *different* secret.
|
||||||
@@ -432,7 +426,7 @@ async def test_media_route_404s_missing_file(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29923)
|
channel = _ch(bus, port=29923)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
target.unlink() # the file vanishes between signing and fetching
|
target.unlink() # the file vanishes between signing and fetching
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
@@ -489,7 +483,7 @@ async def test_media_route_serves_svg_with_strict_csp(
|
|||||||
|
|
||||||
channel = _ch(bus, port=29928)
|
channel = _ch(bus, port=29928)
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
url_path = _sign_media_path(channel, target)
|
url_path = channel.gateway.media.sign_media_path(target)
|
||||||
assert url_path is not None
|
assert url_path is not None
|
||||||
server_task = asyncio.create_task(channel.start())
|
server_task = asyncio.create_task(channel.start())
|
||||||
try:
|
try:
|
||||||
@@ -503,3 +497,91 @@ async def test_media_route_serves_svg_with_strict_csp(
|
|||||||
assert resp.headers.get("x-content-type-options") == "nosniff"
|
assert resp.headers.get("x-content-type-options") == "nosniff"
|
||||||
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
|
assert "default-src 'none'" in resp.headers.get("content-security-policy", "")
|
||||||
assert "sandbox" in resp.headers.get("content-security-policy", "")
|
assert "sandbox" in resp.headers.get("content-security-policy", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# /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.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
try:
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
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.webui.media_gateway.get_media_dir", return_value=media):
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
try:
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
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
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class WeixinConnectSession:
|
|||||||
channel: WeixinChannel
|
channel: WeixinChannel
|
||||||
current_poll_base_url: str
|
current_poll_base_url: str
|
||||||
refresh_count: int
|
refresh_count: int
|
||||||
force: bool
|
|
||||||
created_wall: float
|
created_wall: float
|
||||||
deadline: float
|
deadline: float
|
||||||
last_error: str | None = None
|
last_error: str | None = None
|
||||||
@@ -48,10 +47,7 @@ class WeixinConnectStore:
|
|||||||
if not session_id:
|
if not session_id:
|
||||||
raise ChannelConnectError("missing WeChat connect session")
|
raise ChannelConnectError("missing WeChat connect session")
|
||||||
if action == "poll":
|
if action == "poll":
|
||||||
return await self.poll(
|
return await self.poll(session_id)
|
||||||
session_id,
|
|
||||||
verify_code=(query_first(query, "verify_code") or "").strip(),
|
|
||||||
)
|
|
||||||
if action == "cancel":
|
if action == "cancel":
|
||||||
return await self.cancel(session_id)
|
return await self.cancel(session_id)
|
||||||
raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404)
|
raise ChannelConnectError(f"unsupported WeChat connect action: {action}", status=404)
|
||||||
@@ -73,7 +69,7 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
channel.connect_open_client()
|
channel.connect_open_client()
|
||||||
try:
|
try:
|
||||||
qrcode_id, qr_url = await channel.connect_fetch_qr_code(force=force)
|
qrcode_id, qr_url = await channel.connect_fetch_qr_code()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
await self._close_channel(channel)
|
await self._close_channel(channel)
|
||||||
raise ChannelConnectError(
|
raise ChannelConnectError(
|
||||||
@@ -90,13 +86,12 @@ class WeixinConnectStore:
|
|||||||
channel=channel,
|
channel=channel,
|
||||||
current_poll_base_url=channel.connect_base_url,
|
current_poll_base_url=channel.connect_base_url,
|
||||||
refresh_count=0,
|
refresh_count=0,
|
||||||
force=force,
|
|
||||||
created_wall=now_wall,
|
created_wall=now_wall,
|
||||||
deadline=time.monotonic() + 600,
|
deadline=time.monotonic() + 600,
|
||||||
)
|
)
|
||||||
return self._start_payload(self._sessions[session_id])
|
return self._start_payload(self._sessions[session_id])
|
||||||
|
|
||||||
async def poll(self, session_id: str, *, verify_code: str = "") -> dict[str, Any]:
|
async def poll(self, session_id: str) -> dict[str, Any]:
|
||||||
await self._cleanup()
|
await self._cleanup()
|
||||||
session = self._sessions.get(session_id)
|
session = self._sessions.get(session_id)
|
||||||
if session is None:
|
if session is None:
|
||||||
@@ -110,7 +105,6 @@ class WeixinConnectStore:
|
|||||||
status_data = await session.channel.connect_poll_qr_code(
|
status_data = await session.channel.connect_poll_qr_code(
|
||||||
base_url=session.current_poll_base_url,
|
base_url=session.current_poll_base_url,
|
||||||
qrcode_id=session.qrcode_id,
|
qrcode_id=session.qrcode_id,
|
||||||
verify_code=verify_code,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if session.channel.connect_poll_error_is_retryable(exc):
|
if session.channel.connect_poll_error_is_retryable(exc):
|
||||||
@@ -126,8 +120,6 @@ class WeixinConnectStore:
|
|||||||
|
|
||||||
status_payload = status_data
|
status_payload = status_data
|
||||||
status = status_payload.get("status", "")
|
status = status_payload.get("status", "")
|
||||||
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
|
|
||||||
|
|
||||||
if status == "confirmed":
|
if status == "confirmed":
|
||||||
if self._sessions.get(session_id) is not session:
|
if self._sessions.get(session_id) is not session:
|
||||||
return {
|
return {
|
||||||
@@ -165,77 +157,9 @@ class WeixinConnectStore:
|
|||||||
)
|
)
|
||||||
return self._pending_payload(session)
|
return self._pending_payload(session)
|
||||||
|
|
||||||
if status == "need_verifycode":
|
|
||||||
return self._pending_payload(
|
|
||||||
session,
|
|
||||||
challenge="verify_code",
|
|
||||||
message=(
|
|
||||||
"That verification code did not match. Enter the new number shown in WeChat."
|
|
||||||
if verify_code
|
|
||||||
else "Enter the number shown in WeChat to continue."
|
|
||||||
),
|
|
||||||
verification_failed=bool(verify_code),
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == "verify_code_blocked":
|
|
||||||
session.refresh_count += 1
|
|
||||||
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
|
||||||
self._sessions.pop(session_id, None)
|
|
||||||
await self._close_channel(session.channel)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"status": "failed",
|
|
||||||
"message": "Too many incorrect verification attempts. Try again later.",
|
|
||||||
}
|
|
||||||
try:
|
|
||||||
session.qrcode_id, session.qr_url = (
|
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
self._sessions.pop(session_id, None)
|
|
||||||
await self._close_channel(session.channel)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"status": "failed",
|
|
||||||
"message": f"Could not refresh WeChat QR code: {exc}",
|
|
||||||
}
|
|
||||||
session.current_poll_base_url = session.channel.connect_base_url
|
|
||||||
return self._pending_payload(
|
|
||||||
session,
|
|
||||||
message="Verification was blocked. Scan the refreshed QR code to try again.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == "binded_redirect":
|
|
||||||
if session.force:
|
|
||||||
self._sessions.pop(session_id, None)
|
|
||||||
await self._close_channel(session.channel)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"status": "failed",
|
|
||||||
"message": (
|
|
||||||
"Unable to complete a new WeChat login. "
|
|
||||||
"Start again and scan with the account you want to connect."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
if not session.channel.connect_load_state():
|
|
||||||
self._sessions.pop(session_id, None)
|
|
||||||
await self._close_channel(session.channel)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"status": "failed",
|
|
||||||
"message": (
|
|
||||||
"WeChat reports an existing binding, but no local credentials were found."
|
|
||||||
),
|
|
||||||
}
|
|
||||||
self._sessions.pop(session_id, None)
|
|
||||||
await self._close_channel(session.channel)
|
|
||||||
return {
|
|
||||||
"session_id": session_id,
|
|
||||||
"status": "succeeded",
|
|
||||||
"message": "WeChat is already connected to this nanobot instance.",
|
|
||||||
}
|
|
||||||
|
|
||||||
if status == "expired":
|
if status == "expired":
|
||||||
|
from nanobot.channels.weixin.runtime import MAX_QR_REFRESH_COUNT
|
||||||
|
|
||||||
session.refresh_count += 1
|
session.refresh_count += 1
|
||||||
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
if session.refresh_count > MAX_QR_REFRESH_COUNT:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -247,7 +171,7 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
session.qrcode_id, session.qr_url = (
|
session.qrcode_id, session.qr_url = (
|
||||||
await session.channel.connect_fetch_qr_code(force=session.force)
|
await session.channel.connect_fetch_qr_code()
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._sessions.pop(session_id, None)
|
self._sessions.pop(session_id, None)
|
||||||
@@ -314,25 +238,15 @@ class WeixinConnectStore:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _pending_payload(
|
def _pending_payload(session: WeixinConnectSession) -> dict[str, Any]:
|
||||||
session: WeixinConnectSession,
|
return {
|
||||||
*,
|
|
||||||
challenge: str = "",
|
|
||||||
message: str = "Waiting for WeChat scan.",
|
|
||||||
verification_failed: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"session_id": session.id,
|
"session_id": session.id,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"qr_url": session.qr_url,
|
"qr_url": session.qr_url,
|
||||||
"interval_ms": 2000,
|
"interval_ms": 2000,
|
||||||
"expires_at_ms": int((session.created_wall + 600) * 1000),
|
"expires_at_ms": int((session.created_wall + 600) * 1000),
|
||||||
"message": message,
|
"message": "Waiting for WeChat scan.",
|
||||||
}
|
}
|
||||||
if challenge:
|
|
||||||
payload["challenge"] = challenge
|
|
||||||
payload["verification_failed"] = verification_failed
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["WeixinConnectStore"]
|
__all__ = ["WeixinConnectStore"]
|
||||||
|
|||||||
@@ -10,20 +10,6 @@ SETUP_SPEC = ChannelSetupSpec(
|
|||||||
fields={
|
fields={
|
||||||
"token": field("secret"),
|
"token": field("secret"),
|
||||||
"allowFrom": field("list"),
|
"allowFrom": field("list"),
|
||||||
"baseUrl": field(default="https://ilinkai.weixin.qq.com"),
|
|
||||||
"cdnBaseUrl": field(default="https://novac2c.cdn.weixin.qq.com/c2c"),
|
|
||||||
"routeTag": field(),
|
|
||||||
"stateDir": field(),
|
|
||||||
"pollTimeout": field("int", default=35),
|
|
||||||
"sendProgress": field("bool", default=False),
|
|
||||||
"sendToolHints": field("bool", default=False),
|
|
||||||
"replyProgressMessages": field("bool", default=False),
|
|
||||||
"replyProgressMaxMessages": field("int", default=2),
|
|
||||||
"contextMessageBudget": field("int", default=8),
|
|
||||||
"streaming": field("bool", default=True),
|
|
||||||
"blockStreaming": field("bool", default=False),
|
|
||||||
"blockStreamingMinChars": field("int", default=1200),
|
|
||||||
"blockStreamingMaxMessages": field("int", default=3),
|
|
||||||
},
|
},
|
||||||
required=(required("token"),),
|
required=(required("token"),),
|
||||||
official_url="https://weixin.qq.com/",
|
official_url="https://weixin.qq.com/",
|
||||||
|
|||||||
+159
-1005
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,7 @@ async def test_weixin_connect_store_saves_confirmed_qr_login(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-1", "https://qr.example/1"
|
return "qr-1", "https://qr.example/1"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -88,31 +86,14 @@ async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
|
|||||||
)
|
)
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
||||||
|
|
||||||
observed_force: list[bool] = []
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
|
return "qr-reconnect", "https://qr.example/reconnect"
|
||||||
async def fake_fetch_qr_code(
|
|
||||||
self: WeixinChannel,
|
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
observed_force.append(force)
|
|
||||||
return f"qr-reconnect-{len(observed_force)}", "https://qr.example/reconnect"
|
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
|
||||||
self: WeixinChannel,
|
|
||||||
**_kwargs: Any,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {"status": "expired"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
store = WeixinConnectStore()
|
||||||
started = await store.start(force=True)
|
started = await store.start(force=True)
|
||||||
refreshed = await store.poll(started["session_id"])
|
|
||||||
|
|
||||||
assert refreshed["status"] == "pending"
|
|
||||||
assert observed_force == [True, True]
|
|
||||||
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
|
||||||
cancelled = await store.cancel(started["session_id"])
|
cancelled = await store.cancel(started["session_id"])
|
||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
@@ -135,9 +116,7 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
poll_started = asyncio.Event()
|
poll_started = asyncio.Event()
|
||||||
release_poll = asyncio.Event()
|
release_poll = asyncio.Event()
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-cancel", "https://qr.example/cancel"
|
return "qr-cancel", "https://qr.example/cancel"
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
async def fake_api_get_with_base(
|
||||||
@@ -168,138 +147,3 @@ async def test_weixin_cancel_wins_over_inflight_confirmation(
|
|||||||
assert cancelled["status"] == "cancelled"
|
assert cancelled["status"] == "cancelled"
|
||||||
assert completed["status"] == "cancelled"
|
assert completed["status"] == "cancelled"
|
||||||
assert not (state_dir / "account.json").exists()
|
assert not (state_dir / "account.json").exists()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_weixin_connect_store_handles_verification_code(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
state_dir = tmp_path / "weixin-state"
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
save_config(
|
|
||||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
|
||||||
config_path,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-verify", "https://qr.example/verify"
|
|
||||||
|
|
||||||
responses = [
|
|
||||||
{"status": "need_verifycode"},
|
|
||||||
{
|
|
||||||
"status": "confirmed",
|
|
||||||
"bot_token": "verified-token",
|
|
||||||
"ilink_user_id": "wx-user",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
|
||||||
self: WeixinChannel,
|
|
||||||
*,
|
|
||||||
params: dict[str, Any],
|
|
||||||
**_kwargs: Any,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
if len(responses) == 1:
|
|
||||||
assert params == {"qrcode": "qr-verify", "verify_code": "1234"}
|
|
||||||
return responses.pop(0)
|
|
||||||
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
|
||||||
started = await store.start()
|
|
||||||
challenged = await store.poll(started["session_id"])
|
|
||||||
completed = await store.handle(
|
|
||||||
"poll",
|
|
||||||
{
|
|
||||||
"session_id": [started["session_id"]],
|
|
||||||
"verify_code": ["1234"],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert challenged["status"] == "pending"
|
|
||||||
assert challenged["challenge"] == "verify_code"
|
|
||||||
assert completed["status"] == "succeeded"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_weixin_connect_store_rejects_existing_binding_during_forced_login(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
state_dir = tmp_path / "weixin-state"
|
|
||||||
state_dir.mkdir()
|
|
||||||
(state_dir / "account.json").write_text(
|
|
||||||
json.dumps({"token": "working-token"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
save_config(
|
|
||||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
|
||||||
config_path,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
|
||||||
self: WeixinChannel,
|
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
assert force is True
|
|
||||||
return "qr-existing", "https://qr.example/existing"
|
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
|
||||||
self: WeixinChannel,
|
|
||||||
**_kwargs: Any,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {"status": "binded_redirect"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
|
||||||
started = await store.start(force=True)
|
|
||||||
completed = await store.poll(started["session_id"])
|
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
|
||||||
assert "new WeChat login" in completed["message"]
|
|
||||||
assert json.loads((state_dir / "account.json").read_text())["token"] == "working-token"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_weixin_connect_store_rejects_existing_binding_without_local_credentials(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
state_dir = tmp_path / "weixin-state"
|
|
||||||
config_path = tmp_path / "config.json"
|
|
||||||
save_config(
|
|
||||||
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
|
|
||||||
config_path,
|
|
||||||
)
|
|
||||||
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
|
|
||||||
|
|
||||||
async def fake_fetch_qr_code(
|
|
||||||
self: WeixinChannel, **_kwargs: Any
|
|
||||||
) -> tuple[str, str]:
|
|
||||||
return "qr-missing", "https://qr.example/missing"
|
|
||||||
|
|
||||||
async def fake_api_get_with_base(
|
|
||||||
self: WeixinChannel,
|
|
||||||
**_kwargs: Any,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
return {"status": "binded_redirect"}
|
|
||||||
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
|
|
||||||
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
|
|
||||||
|
|
||||||
store = WeixinConnectStore()
|
|
||||||
started = await store.start(force=False)
|
|
||||||
completed = await store.poll(started["session_id"])
|
|
||||||
|
|
||||||
assert completed["status"] == "failed"
|
|
||||||
assert "no local credentials" in completed["message"]
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from nanobot.channels.weixin.runtime import (
|
|||||||
ITEM_TEXT,
|
ITEM_TEXT,
|
||||||
MESSAGE_TYPE_BOT,
|
MESSAGE_TYPE_BOT,
|
||||||
WEIXIN_CHANNEL_VERSION,
|
WEIXIN_CHANNEL_VERSION,
|
||||||
WeixinAuthError,
|
|
||||||
WeixinChannel,
|
WeixinChannel,
|
||||||
WeixinConfig,
|
WeixinConfig,
|
||||||
_decrypt_aes_ecb,
|
_decrypt_aes_ecb,
|
||||||
@@ -68,11 +67,11 @@ def test_make_headers_includes_route_tag_when_configured() -> None:
|
|||||||
assert headers["Authorization"] == "Bearer token"
|
assert headers["Authorization"] == "Bearer token"
|
||||||
assert headers["SKRouteTag"] == "123"
|
assert headers["SKRouteTag"] == "123"
|
||||||
assert headers["iLink-App-Id"] == "bot"
|
assert headers["iLink-App-Id"] == "bot"
|
||||||
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (4 << 8) | 6)
|
assert headers["iLink-App-ClientVersion"] == str((2 << 16) | (1 << 8) | 1)
|
||||||
|
|
||||||
|
|
||||||
def test_channel_version_matches_reference_plugin_version() -> None:
|
def test_channel_version_matches_reference_plugin_version() -> None:
|
||||||
assert WEIXIN_CHANNEL_VERSION == "2.4.6"
|
assert WEIXIN_CHANNEL_VERSION == "2.1.1"
|
||||||
|
|
||||||
|
|
||||||
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
def test_save_and_load_state_persists_context_tokens(tmp_path) -> None:
|
||||||
@@ -160,29 +159,6 @@ def test_save_state_persists_explicit_config_token_over_stale_state(tmp_path) ->
|
|||||||
assert saved["get_updates_buf"] == "current-cursor"
|
assert saved["get_updates_buf"] == "current-cursor"
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_preserves_qr_replacement_of_configured_token(tmp_path) -> None:
|
|
||||||
config = WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
)
|
|
||||||
old_runtime = WeixinChannel(config, MessageBus())
|
|
||||||
old_runtime._token = "configured-token"
|
|
||||||
|
|
||||||
replacement = WeixinChannel(config, MessageBus())
|
|
||||||
replacement.connect_commit_account(
|
|
||||||
token="replacement-token",
|
|
||||||
base_url="https://new.example",
|
|
||||||
)
|
|
||||||
|
|
||||||
old_runtime._save_state()
|
|
||||||
|
|
||||||
saved = json.loads((tmp_path / "account.json").read_text())
|
|
||||||
assert saved["token"] == "replacement-token"
|
|
||||||
assert saved["base_url"] == "https://new.example"
|
|
||||||
|
|
||||||
|
|
||||||
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_path) -> None:
|
||||||
channel = WeixinChannel(
|
channel = WeixinChannel(
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||||
@@ -196,86 +172,6 @@ def test_save_state_with_empty_runtime_token_preserves_persisted_account(tmp_pat
|
|||||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_login_force_ignores_persisted_account_through_qr_flow(tmp_path) -> None:
|
|
||||||
persisted = {
|
|
||||||
"token": "persisted-token",
|
|
||||||
"get_updates_buf": "persisted-cursor",
|
|
||||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
|
||||||
"typing_tickets": {"wx-user": {"ticket": "ticket-persisted"}},
|
|
||||||
"base_url": "https://persisted.example",
|
|
||||||
}
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
(tmp_path / "account.json").write_text(
|
|
||||||
json.dumps(persisted),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel._print_qr_code = lambda _url: None
|
|
||||||
channel._api_post = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
|
||||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
channel._api_get_with_base = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"status": "expired"},
|
|
||||||
{"status": "binded_redirect"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
ok = await channel.login(force=True)
|
|
||||||
|
|
||||||
assert ok is False
|
|
||||||
assert [call.args[1]["local_token_list"] for call in channel._api_post.await_args_list] == [
|
|
||||||
[],
|
|
||||||
[],
|
|
||||||
]
|
|
||||||
assert channel._token == ""
|
|
||||||
assert channel._get_updates_buf == ""
|
|
||||||
assert channel._context_tokens == {}
|
|
||||||
assert channel._typing_tickets == {}
|
|
||||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
|
||||||
assert json.loads((tmp_path / "account.json").read_text()) == persisted
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_login_without_force_reuses_persisted_account(tmp_path) -> None:
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
(tmp_path / "account.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"token": "persisted-token",
|
|
||||||
"get_updates_buf": "persisted-cursor",
|
|
||||||
"context_tokens": {"wx-user": "ctx-persisted"},
|
|
||||||
"base_url": "https://persisted.example",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel._qr_login = AsyncMock(return_value=False)
|
|
||||||
|
|
||||||
ok = await channel.login(force=False)
|
|
||||||
|
|
||||||
assert ok is True
|
|
||||||
channel._qr_login.assert_not_awaited()
|
|
||||||
assert channel._token == "persisted-token"
|
|
||||||
assert channel._get_updates_buf == "persisted-cursor"
|
|
||||||
assert channel._context_tokens == {"wx-user": "ctx-persisted"}
|
|
||||||
assert channel.config.base_url == "https://persisted.example"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_deduplicates_inbound_ids() -> None:
|
async def test_process_message_deduplicates_inbound_ids() -> None:
|
||||||
channel, bus = _make_channel()
|
channel, bus = _make_channel()
|
||||||
@@ -546,15 +442,15 @@ async def test_send_without_context_token_raises() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_raises_when_authentication_is_required() -> None:
|
async def test_send_raises_when_session_is_paused() -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._client = object()
|
channel._client = object()
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
channel._context_tokens["wx-user"] = "ctx-2"
|
channel._context_tokens["wx-user"] = "ctx-2"
|
||||||
channel._auth_required = True
|
channel._pause_session(60)
|
||||||
channel._send_text = AsyncMock()
|
channel._send_text = AsyncMock()
|
||||||
|
|
||||||
with pytest.raises(WeixinAuthError, match="bot token is stale"):
|
with pytest.raises(RuntimeError, match="session paused"):
|
||||||
await channel.send(
|
await channel.send(
|
||||||
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
type("Msg", (), {"chat_id": "wx-user", "content": "pong", "media": [], "metadata": {}})()
|
||||||
)
|
)
|
||||||
@@ -629,21 +525,20 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_once_requires_login_on_stale_token() -> None:
|
async def test_poll_once_pauses_session_on_expired_errcode() -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._client = SimpleNamespace(timeout=None)
|
channel._client = SimpleNamespace(timeout=None)
|
||||||
channel._token = "token"
|
channel._token = "token"
|
||||||
channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"})
|
channel._api_post = AsyncMock(return_value={"ret": 0, "errcode": -14, "errmsg": "expired"})
|
||||||
|
|
||||||
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
|
|
||||||
await channel._poll_once()
|
await channel._poll_once()
|
||||||
|
|
||||||
assert channel._auth_required is True
|
assert channel._session_pause_remaining_s() > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
async def test_poll_once_reloads_refreshed_state_after_session_pause(
|
||||||
tmp_path,
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
channel = WeixinChannel(
|
channel = WeixinChannel(
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
||||||
@@ -655,13 +550,8 @@ async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
|||||||
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
json.dumps({"token": "new-token", "base_url": "https://new.example"}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
channel._client = object()
|
channel._session_pause_until = time.time() + 10
|
||||||
channel._api_post = AsyncMock(
|
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||||
side_effect=[
|
|
||||||
{"ret": 0, "errcode": -14, "errmsg": "stale"},
|
|
||||||
{"ret": 0},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel._poll_once()
|
await channel._poll_once()
|
||||||
|
|
||||||
@@ -670,8 +560,8 @@ async def test_poll_once_reloads_refreshed_state_after_stale_token(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_poll_once_keeps_explicit_token_and_requires_login(
|
async def test_poll_once_keeps_explicit_token_after_session_pause(
|
||||||
tmp_path,
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||||
) -> None:
|
) -> None:
|
||||||
channel = WeixinChannel(
|
channel = WeixinChannel(
|
||||||
WeixinConfig(
|
WeixinConfig(
|
||||||
@@ -687,132 +577,24 @@ async def test_poll_once_keeps_explicit_token_and_requires_login(
|
|||||||
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
json.dumps({"token": "stale-token", "base_url": "https://stale.example"}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
channel._client = object()
|
channel._session_pause_until = time.time() + 10
|
||||||
channel._api_post = AsyncMock(
|
monkeypatch.setattr(weixin_mod.asyncio, "sleep", AsyncMock())
|
||||||
return_value={"ret": 0, "errcode": -14, "errmsg": "stale"}
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(WeixinAuthError, match="no replacement credentials"):
|
|
||||||
await channel._poll_once()
|
await channel._poll_once()
|
||||||
|
|
||||||
assert channel._token == "configured-token"
|
assert channel._token == "configured-token"
|
||||||
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
assert channel.config.base_url == "https://ilinkai.weixin.qq.com"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_poll_once_loads_qr_replacement_for_configured_token(tmp_path) -> None:
|
|
||||||
config = WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
)
|
|
||||||
replacement = WeixinChannel(config, MessageBus())
|
|
||||||
replacement.connect_commit_account(
|
|
||||||
token="replacement-token",
|
|
||||||
base_url="https://new.example",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel = WeixinChannel(config, MessageBus())
|
|
||||||
channel._token = "configured-token"
|
|
||||||
channel._client = object()
|
|
||||||
channel._api_post = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"ret": 0, "errcode": -14, "errmsg": "stale"},
|
|
||||||
{"ret": 0},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
await channel._poll_once()
|
|
||||||
|
|
||||||
assert channel._token == "replacement-token"
|
|
||||||
assert channel.config.base_url == "https://new.example"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_start_uses_qr_replacement_for_configured_token(tmp_path) -> None:
|
|
||||||
config = WeixinConfig(
|
|
||||||
enabled=True,
|
|
||||||
allow_from=["*"],
|
|
||||||
token="configured-token",
|
|
||||||
state_dir=str(tmp_path),
|
|
||||||
)
|
|
||||||
connector = WeixinChannel(config, MessageBus())
|
|
||||||
connector.connect_commit_account(
|
|
||||||
token="replacement-token",
|
|
||||||
base_url="https://new.example",
|
|
||||||
)
|
|
||||||
|
|
||||||
channel = WeixinChannel(config, MessageBus())
|
|
||||||
observed_tokens: list[str] = []
|
|
||||||
|
|
||||||
async def stop_after_first_poll() -> None:
|
|
||||||
observed_tokens.append(channel._token)
|
|
||||||
channel._running = False
|
|
||||||
|
|
||||||
channel._notify_lifecycle = AsyncMock() # type: ignore[method-assign]
|
|
||||||
channel._poll_once = stop_after_first_poll # type: ignore[method-assign]
|
|
||||||
|
|
||||||
await channel.start()
|
|
||||||
await channel.stop()
|
|
||||||
|
|
||||||
assert observed_tokens == ["replacement-token"]
|
|
||||||
assert channel.config.base_url == "https://new.example"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_manager_surfaces_actionable_weixin_auth_error_without_traceback(
|
|
||||||
tmp_path,
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
from nanobot.channels import manager as manager_mod
|
|
||||||
|
|
||||||
channel = WeixinChannel(
|
|
||||||
WeixinConfig(enabled=True, allow_from=["*"], state_dir=str(tmp_path)),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
channel.start = AsyncMock( # type: ignore[method-assign]
|
|
||||||
side_effect=WeixinAuthError(
|
|
||||||
"getupdates",
|
|
||||||
errcode=-14,
|
|
||||||
errmsg="stale",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
errors: list[str] = []
|
|
||||||
tracebacks: list[str] = []
|
|
||||||
monkeypatch.setattr(
|
|
||||||
manager_mod.logger,
|
|
||||||
"error",
|
|
||||||
lambda message, *args: errors.append(message.format(*args)),
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
manager_mod.logger,
|
|
||||||
"exception",
|
|
||||||
lambda message, *args: tracebacks.append(message.format(*args)),
|
|
||||||
)
|
|
||||||
manager = manager_mod.ChannelManager.__new__(manager_mod.ChannelManager)
|
|
||||||
manager._channel_errors = {}
|
|
||||||
|
|
||||||
await manager._start_channel("weixin", channel)
|
|
||||||
|
|
||||||
assert manager._channel_errors["weixin"] == (
|
|
||||||
"WeChat login expired. Scan again to reconnect."
|
|
||||||
)
|
|
||||||
assert errors == [
|
|
||||||
"Failed to start channel weixin: WeChat login expired. Scan again to reconnect."
|
|
||||||
]
|
|
||||||
assert tracebacks == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
|
||||||
no_qr_poll_delay,
|
no_qr_poll_delay,
|
||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._api_post = AsyncMock(
|
channel._api_get = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||||
@@ -845,7 +627,7 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
|
|||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._api_post = AsyncMock(
|
channel._api_get = AsyncMock(
|
||||||
side_effect=[
|
side_effect=[
|
||||||
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
{"qrcode": "qr-1", "qrcode_img_content": "url-1"},
|
||||||
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
{"qrcode": "qr-2", "qrcode_img_content": "url-2"},
|
||||||
@@ -873,7 +655,7 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status(
|
|||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||||
|
|
||||||
@@ -907,7 +689,7 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
|
|||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||||
|
|
||||||
@@ -941,7 +723,7 @@ async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
|
|||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])
|
channel._fetch_qr_code = AsyncMock(side_effect=[("qr-1", "url-1"), ("qr-2", "url-2")])
|
||||||
|
|
||||||
@@ -1233,7 +1015,7 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
|
|||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||||
|
|
||||||
@@ -1263,7 +1045,7 @@ async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
|
|||||||
) -> None:
|
) -> None:
|
||||||
channel, _bus = _make_channel()
|
channel, _bus = _make_channel()
|
||||||
channel._running = True
|
channel._running = True
|
||||||
channel._save_state = lambda **_kwargs: None
|
channel._save_state = lambda: None
|
||||||
channel._print_qr_code = lambda url: None
|
channel._print_qr_code = lambda url: None
|
||||||
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
channel._fetch_qr_code = AsyncMock(return_value=("qr-1", "url-1"))
|
||||||
|
|
||||||
@@ -1298,32 +1080,6 @@ def test_decrypt_aes_ecb_strips_valid_pkcs7_padding() -> None:
|
|||||||
assert decrypted == plaintext
|
assert decrypted == plaintext
|
||||||
|
|
||||||
|
|
||||||
def test_missing_aes_dependency_recommends_weixin_plugin(monkeypatch) -> None:
|
|
||||||
real_import = __import__
|
|
||||||
|
|
||||||
def fake_import(name, *args, **kwargs):
|
|
||||||
if name.startswith(("Crypto", "cryptography")):
|
|
||||||
raise ImportError("missing AES dependency")
|
|
||||||
return real_import(name, *args, **kwargs)
|
|
||||||
|
|
||||||
warnings: list[str] = []
|
|
||||||
monkeypatch.setattr("builtins.__import__", fake_import)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
weixin_mod.logger,
|
|
||||||
"warning",
|
|
||||||
lambda message, *args: warnings.append(message.format(*args)),
|
|
||||||
)
|
|
||||||
key_b64 = "MDEyMzQ1Njc4OWFiY2RlZg=="
|
|
||||||
data = b"unencrypted media"
|
|
||||||
|
|
||||||
assert _encrypt_aes_ecb(data, key_b64) == data
|
|
||||||
assert _decrypt_aes_ecb(data, key_b64) == data
|
|
||||||
assert warnings == [
|
|
||||||
"Cannot encrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
|
|
||||||
"Cannot decrypt media. Run `nanobot plugins enable weixin` to install WeChat support.",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class _DummyDownloadResponse:
|
class _DummyDownloadResponse:
|
||||||
def __init__(self, content: bytes, status_code: int = 200) -> None:
|
def __init__(self, content: bytes, status_code: int = 200) -> None:
|
||||||
self.content = content
|
self.content = content
|
||||||
@@ -1656,7 +1412,7 @@ async def test_send_text_raises_on_api_error() -> None:
|
|||||||
return_value={"errcode": -14, "errmsg": "session expired"}
|
return_value={"errcode": -14, "errmsg": "session expired"}
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(WeixinAuthError, match="WeChat sendmessage failed.*errcode=-14"):
|
with pytest.raises(RuntimeError, match="WeChat send text error.*-14"):
|
||||||
await channel._send_text("wx-user", "hello", "ctx-expired")
|
await channel._send_text("wx-user", "hello", "ctx-expired")
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
channel._api_post.assert_awaited_once()
|
||||||
@@ -1689,7 +1445,7 @@ async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
|
|||||||
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="WeChat sendmessage failed.*ret=-100.*errcode=0"):
|
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
|
||||||
await channel._send_text("wx-user", "hello", "ctx-ok")
|
await channel._send_text("wx-user", "hello", "ctx-ok")
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
channel._api_post.assert_awaited_once()
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.bus.events import OutboundMessage
|
|
||||||
from nanobot.bus.outbound_events import ProgressEvent
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.channels.manager import ChannelManager
|
|
||||||
from nanobot.channels.weixin.manifest import SETUP_SPEC
|
|
||||||
from nanobot.channels.weixin.runtime import (
|
|
||||||
ITEM_TOOL_CALL_RESULT,
|
|
||||||
ITEM_TOOL_CALL_START,
|
|
||||||
WEIXIN_MAX_MESSAGE_LEN,
|
|
||||||
WeixinAPIError,
|
|
||||||
WeixinAuthError,
|
|
||||||
WeixinChannel,
|
|
||||||
WeixinConfig,
|
|
||||||
WeixinQuotaError,
|
|
||||||
sanitize_weixin_markdown,
|
|
||||||
split_weixin_message,
|
|
||||||
)
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
|
|
||||||
def _channel(**config: object) -> WeixinChannel:
|
|
||||||
return WeixinChannel(
|
|
||||||
WeixinConfig.model_validate(
|
|
||||||
{"enabled": True, "allowFrom": ["*"], **config}
|
|
||||||
),
|
|
||||||
MessageBus(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ready_channel(**config: object) -> WeixinChannel:
|
|
||||||
channel = _channel(**config)
|
|
||||||
channel._client = object()
|
|
||||||
channel._token = "bot-token"
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-1"
|
|
||||||
channel._context_token_at["wx-user"] = time.time()
|
|
||||||
channel._typing_tickets["wx-user"] = {
|
|
||||||
"ticket": "",
|
|
||||||
"next_fetch_at": time.time() + 3600,
|
|
||||||
}
|
|
||||||
return channel
|
|
||||||
|
|
||||||
|
|
||||||
def test_weixin_defaults_protect_context_quota() -> None:
|
|
||||||
config = WeixinConfig()
|
|
||||||
|
|
||||||
assert WEIXIN_MAX_MESSAGE_LEN == 1800
|
|
||||||
assert config.send_progress is False
|
|
||||||
assert config.send_tool_hints is False
|
|
||||||
assert config.reply_progress_messages is False
|
|
||||||
assert config.context_message_budget == 8
|
|
||||||
assert config.block_streaming is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_weixin_webui_manifest_covers_runtime_configuration() -> None:
|
|
||||||
runtime_fields = set(WeixinConfig().model_dump(mode="json", by_alias=True))
|
|
||||||
|
|
||||||
assert set(SETUP_SPEC.fields) == runtime_fields - {"enabled"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_reply_progress_opt_in_enables_progress_transport() -> None:
|
|
||||||
config = WeixinConfig(reply_progress_messages=True)
|
|
||||||
|
|
||||||
assert config.send_progress is True
|
|
||||||
assert config.send_tool_hints is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("section", "send_progress", "send_tool_hints"),
|
|
||||||
[
|
|
||||||
({"enabled": True}, False, False),
|
|
||||||
({"enabled": True, "replyProgressMessages": True}, True, True),
|
|
||||||
({"enabled": True, "sendProgress": True, "sendToolHints": False}, True, False),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_channel_manager_preserves_weixin_quota_defaults(
|
|
||||||
section: dict[str, object],
|
|
||||||
send_progress: bool,
|
|
||||||
send_tool_hints: bool,
|
|
||||||
) -> None:
|
|
||||||
manager = ChannelManager.__new__(ChannelManager)
|
|
||||||
manager.config = Config.model_validate({"channels": {"weixin": section}})
|
|
||||||
manager.bus = MessageBus()
|
|
||||||
|
|
||||||
channel = manager._build_channel("weixin", WeixinChannel, section)
|
|
||||||
|
|
||||||
assert channel.send_progress is send_progress
|
|
||||||
assert channel.send_tool_hints is send_tool_hints
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_channel_manager_does_not_retry_permanent_weixin_error(monkeypatch) -> None:
|
|
||||||
manager = ChannelManager.__new__(ChannelManager)
|
|
||||||
manager.config = Config.model_validate({"channels": {"sendMaxRetries": 3}})
|
|
||||||
manager.bus = MessageBus()
|
|
||||||
channel = _channel()
|
|
||||||
channel.send = AsyncMock(
|
|
||||||
side_effect=WeixinAPIError(
|
|
||||||
"sendmessage",
|
|
||||||
errcode=-1,
|
|
||||||
errmsg="business rejection",
|
|
||||||
retryable=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
sleep = AsyncMock()
|
|
||||||
monkeypatch.setattr("nanobot.channels.manager.asyncio.sleep", sleep)
|
|
||||||
|
|
||||||
await manager._send_with_retry(
|
|
||||||
channel,
|
|
||||||
OutboundMessage(channel="weixin", chat_id="wx-user", content="test"),
|
|
||||||
)
|
|
||||||
|
|
||||||
channel.send.assert_awaited_once()
|
|
||||||
sleep.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_weixin_http_clients_ignore_system_proxy(tmp_path, monkeypatch) -> None:
|
|
||||||
captured: list[dict[str, object]] = []
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def make_client(**kwargs: object) -> FakeClient:
|
|
||||||
captured.append(kwargs)
|
|
||||||
return FakeClient()
|
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.channels.weixin.runtime.httpx.AsyncClient", make_client)
|
|
||||||
|
|
||||||
connect_channel = _channel(stateDir=str(tmp_path / "connect"))
|
|
||||||
connect_channel.connect_open_client()
|
|
||||||
await connect_channel.connect_close_client()
|
|
||||||
|
|
||||||
login_channel = _channel(stateDir=str(tmp_path / "login"))
|
|
||||||
login_channel._qr_login = AsyncMock(return_value=True)
|
|
||||||
assert await login_channel.login() is True
|
|
||||||
|
|
||||||
start_channel = _channel(token="configured-token", stateDir=str(tmp_path / "start"))
|
|
||||||
|
|
||||||
async def stop_after_poll() -> None:
|
|
||||||
start_channel._running = False
|
|
||||||
|
|
||||||
start_channel._notify_lifecycle = AsyncMock()
|
|
||||||
start_channel._poll_once = AsyncMock(side_effect=stop_after_poll)
|
|
||||||
await start_channel.start()
|
|
||||||
await start_channel.stop()
|
|
||||||
|
|
||||||
assert len(captured) == 3
|
|
||||||
assert all(kwargs["trust_env"] is False for kwargs in captured)
|
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_sanitizer_preserves_code_and_escapes_bare_angles() -> None:
|
|
||||||
content = "before <tag> `x<y>`\n```python\na<b\n```\n"
|
|
||||||
|
|
||||||
sanitized = sanitize_weixin_markdown(content)
|
|
||||||
|
|
||||||
assert "before <tag>" in sanitized
|
|
||||||
assert "`x<y>`" in sanitized
|
|
||||||
assert "a<b" in sanitized
|
|
||||||
assert "![drop]" not in sanitized
|
|
||||||
|
|
||||||
|
|
||||||
def test_markdown_split_balances_fences_and_stays_within_limit() -> None:
|
|
||||||
chunks = split_weixin_message("```python\n" + ("x" * 4000) + "\n```")
|
|
||||||
|
|
||||||
assert len(chunks) >= 3
|
|
||||||
assert all(len(chunk) <= WEIXIN_MAX_MESSAGE_LEN for chunk in chunks)
|
|
||||||
assert all(chunk.count("```") % 2 == 0 for chunk in chunks)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qr_fetch_posts_known_local_tokens(tmp_path) -> None:
|
|
||||||
state_dir = tmp_path / "weixin"
|
|
||||||
state_dir.mkdir()
|
|
||||||
(state_dir / "account.json").write_text(
|
|
||||||
json.dumps({"token": "persisted-token"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel = _channel(stateDir=str(state_dir))
|
|
||||||
channel._api_post = AsyncMock(
|
|
||||||
return_value={"qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"}
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
|
|
||||||
channel._api_post.assert_awaited_once_with(
|
|
||||||
"ilink/bot/get_bot_qrcode?bot_type=3",
|
|
||||||
{"local_token_list": ["persisted-token"]},
|
|
||||||
auth=False,
|
|
||||||
include_base_info=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qr_fetch_retries_without_rejected_local_tokens(tmp_path) -> None:
|
|
||||||
state_dir = tmp_path / "weixin"
|
|
||||||
state_dir.mkdir()
|
|
||||||
(state_dir / "account.json").write_text(
|
|
||||||
json.dumps({"token": "invalid-token"}),
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
channel = _channel(stateDir=str(state_dir))
|
|
||||||
channel._api_post = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"ret": -3},
|
|
||||||
{"ret": 0, "qrcode": "qr-1", "qrcode_img_content": "https://qr.test/1"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
assert await channel._fetch_qr_code() == ("qr-1", "https://qr.test/1")
|
|
||||||
assert [call.args[1] for call in channel._api_post.await_args_list] == [
|
|
||||||
{"local_token_list": ["invalid-token"]},
|
|
||||||
{"local_token_list": []},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_qr_fetch_does_not_retry_invalid_request_without_local_tokens(tmp_path) -> None:
|
|
||||||
channel = _channel(stateDir=str(tmp_path / "weixin"))
|
|
||||||
channel._api_post = AsyncMock(return_value={"ret": -3})
|
|
||||||
|
|
||||||
with pytest.raises(WeixinAPIError, match="get_bot_qrcode failed.*ret=-3"):
|
|
||||||
await channel._fetch_qr_code()
|
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lifecycle_notifications_are_best_effort() -> None:
|
|
||||||
channel = _ready_channel()
|
|
||||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
|
||||||
|
|
||||||
await channel._notify_lifecycle("start")
|
|
||||||
await channel._notify_lifecycle("stop")
|
|
||||||
|
|
||||||
assert [call.args[0] for call in channel._api_post.await_args_list] == [
|
|
||||||
"ilink/bot/msg/notifystart",
|
|
||||||
"ilink/bot/msg/notifystop",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_business_errors_have_explicit_retry_contracts() -> None:
|
|
||||||
channel = _channel()
|
|
||||||
|
|
||||||
with pytest.raises(WeixinQuotaError) as quota:
|
|
||||||
channel._raise_for_api_error("sendmessage", {"ret": -2})
|
|
||||||
with pytest.raises(WeixinAuthError) as auth:
|
|
||||||
channel._raise_for_api_error("getupdates", {"errcode": -14})
|
|
||||||
with pytest.raises(WeixinAPIError) as rejected:
|
|
||||||
channel._raise_for_api_error("sendmessage", {"ret": -100})
|
|
||||||
|
|
||||||
assert channel.should_retry_send_error(quota.value) is False
|
|
||||||
assert channel.should_retry_send_error(auth.value) is False
|
|
||||||
assert channel.should_retry_send_error(rejected.value) is False
|
|
||||||
assert channel.should_retry_send_error(httpx.ReadTimeout("slow")) is True
|
|
||||||
|
|
||||||
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/send")
|
|
||||||
for status_code in (408, 425, 429, 503):
|
|
||||||
response = httpx.Response(status_code, request=request)
|
|
||||||
error = httpx.HTTPStatusError(
|
|
||||||
"retryable response",
|
|
||||||
request=request,
|
|
||||||
response=response,
|
|
||||||
)
|
|
||||||
assert channel.should_retry_send_error(error) is True
|
|
||||||
|
|
||||||
rejected_response = httpx.Response(400, request=request)
|
|
||||||
rejected_http = httpx.HTTPStatusError(
|
|
||||||
"bad request",
|
|
||||||
request=request,
|
|
||||||
response=rejected_response,
|
|
||||||
)
|
|
||||||
assert channel.should_retry_send_error(rejected_http) is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_error_classification_checks_ret_and_errcode_independently() -> None:
|
|
||||||
channel = _channel()
|
|
||||||
|
|
||||||
with pytest.raises(WeixinQuotaError):
|
|
||||||
channel._raise_for_api_error(
|
|
||||||
"sendmessage",
|
|
||||||
{"ret": -2, "errcode": -100},
|
|
||||||
)
|
|
||||||
with pytest.raises(WeixinAuthError):
|
|
||||||
channel._raise_for_api_error(
|
|
||||||
"getupdates",
|
|
||||||
{"ret": -14, "errcode": -100},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stop_cancels_inflight_long_poll() -> None:
|
|
||||||
channel = _channel(token="configured-token")
|
|
||||||
poll_started = asyncio.Event()
|
|
||||||
poll_cancelled = asyncio.Event()
|
|
||||||
|
|
||||||
class FakeClient:
|
|
||||||
async def aclose(self) -> None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def blocking_poll() -> None:
|
|
||||||
poll_started.set()
|
|
||||||
try:
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
poll_cancelled.set()
|
|
||||||
raise
|
|
||||||
|
|
||||||
channel._new_http_client = lambda _timeout: FakeClient() # type: ignore[method-assign]
|
|
||||||
channel._notify_lifecycle = AsyncMock()
|
|
||||||
channel._poll_once = blocking_poll # type: ignore[method-assign]
|
|
||||||
|
|
||||||
start_task = asyncio.create_task(channel.start())
|
|
||||||
await asyncio.wait_for(poll_started.wait(), timeout=1)
|
|
||||||
await asyncio.wait_for(channel.stop(), timeout=1)
|
|
||||||
await asyncio.wait_for(start_task, timeout=1)
|
|
||||||
|
|
||||||
assert poll_cancelled.is_set()
|
|
||||||
assert channel._poll_task is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_retry_reuses_client_id_and_skips_completed_chunks() -> None:
|
|
||||||
channel = _ready_channel()
|
|
||||||
request = httpx.Request("POST", "https://ilinkai.weixin.qq.com/ilink/bot/sendmessage")
|
|
||||||
channel._api_post = AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
{"ret": 0},
|
|
||||||
httpx.ReadTimeout("ambiguous timeout", request=request),
|
|
||||||
{"ret": 0},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
msg = OutboundMessage(
|
|
||||||
channel="weixin",
|
|
||||||
chat_id="wx-user",
|
|
||||||
content="x" * (WEIXIN_MAX_MESSAGE_LEN + 200),
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(httpx.ReadTimeout):
|
|
||||||
await channel.send(msg)
|
|
||||||
await channel.send(msg)
|
|
||||||
|
|
||||||
bodies = [call.args[1] for call in channel._api_post.await_args_list]
|
|
||||||
client_ids = [body["msg"]["client_id"] for body in bodies]
|
|
||||||
assert client_ids[0] != client_ids[1]
|
|
||||||
assert client_ids[1] == client_ids[2]
|
|
||||||
assert channel._context_send_counts["ctx-1"] == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_quota_rejection_defers_final_until_fresh_context() -> None:
|
|
||||||
channel = _ready_channel()
|
|
||||||
channel._api_post = AsyncMock(side_effect=[{"ret": -2}, {"ret": 0}])
|
|
||||||
msg = OutboundMessage(
|
|
||||||
channel="weixin",
|
|
||||||
chat_id="wx-user",
|
|
||||||
content="deferred answer",
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(WeixinQuotaError):
|
|
||||||
await channel.send(msg)
|
|
||||||
first_client_id = channel._api_post.await_args_list[0].args[1]["msg"]["client_id"]
|
|
||||||
assert "wx-user" in channel._deferred_outbound
|
|
||||||
|
|
||||||
channel._context_tokens["wx-user"] = "ctx-2"
|
|
||||||
channel._context_token_at["wx-user"] = time.time()
|
|
||||||
await channel._retry_deferred_messages("wx-user")
|
|
||||||
|
|
||||||
second_client_id = channel._api_post.await_args_list[1].args[1]["msg"]["client_id"]
|
|
||||||
assert second_client_id == first_client_id
|
|
||||||
assert "wx-user" not in channel._deferred_outbound
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_local_context_budget_stops_before_extra_api_call() -> None:
|
|
||||||
channel = _ready_channel(contextMessageBudget=1)
|
|
||||||
channel._api_post = AsyncMock(return_value={"ret": 0})
|
|
||||||
|
|
||||||
await channel._send_text("wx-user", "one", "ctx-1")
|
|
||||||
with pytest.raises(WeixinQuotaError, match="local safety budget"):
|
|
||||||
await channel._send_text("wx-user", "two", "ctx-1")
|
|
||||||
|
|
||||||
channel._api_post.assert_awaited_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_bounded_block_streaming_reserves_one_final_message() -> None:
|
|
||||||
channel = _ready_channel(
|
|
||||||
blockStreaming=True,
|
|
||||||
blockStreamingMinChars=200,
|
|
||||||
blockStreamingMaxMessages=3,
|
|
||||||
)
|
|
||||||
channel._send_text = AsyncMock()
|
|
||||||
|
|
||||||
await channel.send_delta("wx-user", "a" * 250, stream_id="stream-1")
|
|
||||||
await channel.send_delta("wx-user", "b" * 250, stream_id="stream-1")
|
|
||||||
await channel.send_delta("wx-user", "c" * 250, stream_id="stream-1")
|
|
||||||
await channel.send_delta("wx-user", "done", stream_id="stream-1", stream_end=True)
|
|
||||||
|
|
||||||
assert channel._send_text.await_count == 3
|
|
||||||
assert "stream-1" not in channel._stream_buffers
|
|
||||||
assert "stream-1" not in channel._stream_sent_counts
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_structured_progress_is_capped_and_uses_one_run_id() -> None:
|
|
||||||
channel = _ready_channel(
|
|
||||||
replyProgressMessages=True,
|
|
||||||
replyProgressMaxMessages=2,
|
|
||||||
)
|
|
||||||
channel._send_message_item = AsyncMock()
|
|
||||||
events = [
|
|
||||||
{"phase": "start", "call_id": "call-1", "name": "read_file"},
|
|
||||||
{"phase": "end", "call_id": "call-1", "name": "read_file"},
|
|
||||||
{"phase": "start", "call_id": "call-2", "name": "exec"},
|
|
||||||
]
|
|
||||||
|
|
||||||
await channel.send(
|
|
||||||
OutboundMessage(
|
|
||||||
channel="weixin",
|
|
||||||
chat_id="wx-user",
|
|
||||||
content="read_file",
|
|
||||||
event=ProgressEvent(content="read_file", tool_hint=True, tool_events=events),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert channel._send_message_item.await_count == 2
|
|
||||||
first = channel._send_message_item.await_args_list[0]
|
|
||||||
second = channel._send_message_item.await_args_list[1]
|
|
||||||
assert first.args[1]["type"] == ITEM_TOOL_CALL_START
|
|
||||||
assert second.args[1]["type"] == ITEM_TOOL_CALL_RESULT
|
|
||||||
assert first.kwargs["run_id"] == second.kwargs["run_id"]
|
|
||||||
@@ -1,148 +1,25 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import {
|
import { channelTranslator } from "@/channel-plugins/i18n";
|
||||||
channelTranslator,
|
|
||||||
type ChannelTranslator,
|
|
||||||
} from "@/channel-plugins/i18n";
|
|
||||||
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
|
import type { ChannelPluginConnectFlowProps } from "@/channel-plugins/types";
|
||||||
import {
|
import { ChannelQrConnectFlow } from "@/components/settings/channels/ChannelQrConnectFlow";
|
||||||
ChannelQrConnectFlow,
|
|
||||||
type ChannelQrConnectPendingContext,
|
|
||||||
} from "@/components/settings/channels/ChannelQrConnectFlow";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import type { ChannelConnectPayload } from "@/lib/types";
|
|
||||||
|
|
||||||
type WeixinVerificationPayload = ChannelConnectPayload & {
|
|
||||||
challenge: "verify_code";
|
|
||||||
verification_failed?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WEIXIN_AUTH_EXPIRED_MESSAGE =
|
|
||||||
"WeChat login expired. Scan again to reconnect.";
|
|
||||||
|
|
||||||
function isVerificationChallenge(
|
|
||||||
payload: ChannelConnectPayload,
|
|
||||||
): payload is WeixinVerificationPayload {
|
|
||||||
return (
|
|
||||||
"challenge" in payload
|
|
||||||
&& payload.challenge === "verify_code"
|
|
||||||
&& (
|
|
||||||
!("verification_failed" in payload)
|
|
||||||
|| typeof payload.verification_failed === "boolean"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function weixinConnectMessage(
|
|
||||||
payload: ChannelConnectPayload,
|
|
||||||
tx: ChannelTranslator,
|
|
||||||
): string {
|
|
||||||
if (payload.status === "succeeded") {
|
|
||||||
return tx("custom.connected", "WeChat is connected.");
|
|
||||||
}
|
|
||||||
if (payload.status === "expired") {
|
|
||||||
return tx("custom.expired", WEIXIN_AUTH_EXPIRED_MESSAGE);
|
|
||||||
}
|
|
||||||
if (payload.status === "failed") {
|
|
||||||
return payload.message
|
|
||||||
?? tx("custom.failed", "Unable to connect WeChat. Try again.");
|
|
||||||
}
|
|
||||||
if (payload.status === "cancelled") {
|
|
||||||
return tx("custom.stopped", "WeChat login stopped.");
|
|
||||||
}
|
|
||||||
if (isVerificationChallenge(payload)) {
|
|
||||||
return payload.verification_failed
|
|
||||||
? tx(
|
|
||||||
"custom.verifyMismatch",
|
|
||||||
"That code did not match. Enter the new number shown in WeChat.",
|
|
||||||
)
|
|
||||||
: tx(
|
|
||||||
"custom.verifyDescription",
|
|
||||||
"Enter the number shown in WeChat to continue.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return tx("custom.waiting", "Waiting for WeChat scan...");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WeixinConnectFlow({
|
export function WeixinConnectFlow({
|
||||||
token,
|
token,
|
||||||
feature,
|
|
||||||
idleLabel,
|
idleLabel,
|
||||||
connectRequestId,
|
connectRequestId,
|
||||||
onFeaturesUpdate,
|
onFeaturesUpdate,
|
||||||
}: ChannelPluginConnectFlowProps) {
|
}: ChannelPluginConnectFlowProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = channelTranslator(t, "weixin");
|
const tx = channelTranslator(t, "weixin");
|
||||||
const [verificationCode, setVerificationCode] = useState("");
|
|
||||||
const authExpired = feature.runtime_error === WEIXIN_AUTH_EXPIRED_MESSAGE;
|
|
||||||
const scanAgainLabel = t("settings.channels.scanAgain", {
|
|
||||||
defaultValue: "Scan again",
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderVerification = ({
|
|
||||||
connect,
|
|
||||||
busy,
|
|
||||||
poll,
|
|
||||||
}: ChannelQrConnectPendingContext) => {
|
|
||||||
if (!isVerificationChallenge(connect)) return null;
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="mt-3 space-y-2"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const code = verificationCode.trim();
|
|
||||||
if (!code) return;
|
|
||||||
void poll({ verify_code: code }).then((payload) => {
|
|
||||||
if (payload && !isVerificationChallenge(payload)) {
|
|
||||||
setVerificationCode("");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-[12px] font-semibold text-foreground">
|
|
||||||
{tx("custom.verifyTitle", "Verification required")}
|
|
||||||
</div>
|
|
||||||
<p className="text-[12px] leading-5 text-muted-foreground">
|
|
||||||
{weixinConnectMessage(connect, tx)}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
value={verificationCode}
|
|
||||||
onChange={(event) => setVerificationCode(event.target.value)}
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="one-time-code"
|
|
||||||
placeholder={tx("custom.verifyPlaceholder", "Code")}
|
|
||||||
className="h-8 max-w-40"
|
|
||||||
aria-invalid={connect.verification_failed || undefined}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
||||||
disabled={busy || !verificationCode.trim()}
|
|
||||||
>
|
|
||||||
{tx("custom.verifySubmit", "Verify")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelQrConnectFlow
|
<ChannelQrConnectFlow
|
||||||
token={token}
|
token={token}
|
||||||
channelName="weixin"
|
channelName="weixin"
|
||||||
startOptions={{ force: authExpired }}
|
idleLabel={idleLabel}
|
||||||
idleLabel={authExpired ? scanAgainLabel : idleLabel}
|
|
||||||
connectRequestId={connectRequestId}
|
connectRequestId={connectRequestId}
|
||||||
forceOnRepeat
|
forceOnRepeat
|
||||||
onFeaturesUpdate={onFeaturesUpdate}
|
onFeaturesUpdate={onFeaturesUpdate}
|
||||||
pausePolling={isVerificationChallenge}
|
|
||||||
suppressSucceeded={feature.runtime_status === "failed"}
|
|
||||||
renderPending={renderVerification}
|
|
||||||
resolveMessage={(payload) => weixinConnectMessage(payload, tx)}
|
|
||||||
labels={{
|
labels={{
|
||||||
qrAlt: tx("custom.qrAlt", "WeChat login QR code"),
|
qrAlt: tx("custom.qrAlt", "WeChat login QR code"),
|
||||||
scanTitle: tx("custom.scanTitle", "Scan with WeChat"),
|
scanTitle: tx("custom.scanTitle", "Scan with WeChat"),
|
||||||
@@ -154,7 +31,7 @@ export function WeixinConnectFlow({
|
|||||||
connected: tx("custom.connected", "WeChat is connected."),
|
connected: tx("custom.connected", "WeChat is connected."),
|
||||||
stopped: tx("custom.stopped", "WeChat login stopped."),
|
stopped: tx("custom.stopped", "WeChat login stopped."),
|
||||||
connecting: tx("custom.connecting", "Connecting..."),
|
connecting: tx("custom.connecting", "Connecting..."),
|
||||||
scanAgain: scanAgainLabel,
|
scanAgain: t("settings.channels.scanAgain", { defaultValue: "Scan again" }),
|
||||||
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
|
connect: t("settings.channels.connect", { defaultValue: "Connect" }),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,555 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { Check, ChevronDown, ExternalLink, Loader2, Plus } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { channelFieldMessageKey, channelTranslator } from "@/channel-plugins/i18n";
|
|
||||||
import { channelLocaleMessages } from "@/channel-plugins/locale-registry";
|
|
||||||
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
|
|
||||||
import { ToggleButton } from "@/components/settings/ToggleButton";
|
|
||||||
import {
|
|
||||||
chatAppGuideUrl,
|
|
||||||
docsUrlWithBase,
|
|
||||||
type ChannelConfigField,
|
|
||||||
} from "@/components/settings/channels/catalog";
|
|
||||||
import {
|
|
||||||
CredentialForm,
|
|
||||||
channelValuesForSave,
|
|
||||||
defaultChannelFieldValues,
|
|
||||||
} from "@/components/settings/channels/CredentialForm";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
|
||||||
import { normalizeLocale } from "@/i18n/config";
|
|
||||||
import { configureChannel } from "@/lib/api";
|
|
||||||
import { logoFallbackUrls } from "@/lib/provider-brand";
|
|
||||||
import type {
|
|
||||||
ChannelRuntimeStatus,
|
|
||||||
ChannelSetupContractField,
|
|
||||||
NanobotFeatureInfo,
|
|
||||||
} from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
import {
|
|
||||||
WEIXIN_AUTH_EXPIRED_MESSAGE,
|
|
||||||
WeixinConnectFlow,
|
|
||||||
} from "./WeixinConnectFlow";
|
|
||||||
|
|
||||||
export const WEIXIN_PRIMARY_FIELD_KEYS = [
|
|
||||||
"channels.weixin.sendProgress",
|
|
||||||
"channels.weixin.sendToolHints",
|
|
||||||
"channels.weixin.streaming",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const WEIXIN_ADVANCED_FIELD_KEYS = [
|
|
||||||
"channels.weixin.allowFrom",
|
|
||||||
"channels.weixin.token",
|
|
||||||
"channels.weixin.replyProgressMessages",
|
|
||||||
"channels.weixin.replyProgressMaxMessages",
|
|
||||||
"channels.weixin.contextMessageBudget",
|
|
||||||
"channels.weixin.blockStreaming",
|
|
||||||
"channels.weixin.blockStreamingMinChars",
|
|
||||||
"channels.weixin.blockStreamingMaxMessages",
|
|
||||||
"channels.weixin.baseUrl",
|
|
||||||
"channels.weixin.cdnBaseUrl",
|
|
||||||
"channels.weixin.routeTag",
|
|
||||||
"channels.weixin.stateDir",
|
|
||||||
"channels.weixin.pollTimeout",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function WeixinPanel({
|
|
||||||
token,
|
|
||||||
feature,
|
|
||||||
actionKey,
|
|
||||||
chatAppsDocsUrl,
|
|
||||||
showBrandLogos,
|
|
||||||
onAction,
|
|
||||||
onFeaturesUpdate,
|
|
||||||
}: ChannelPluginPanelProps) {
|
|
||||||
const { client } = useClient();
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
||||||
const channelTx = channelTranslator(t, "weixin");
|
|
||||||
const runtimeError = weixinRuntimeError(feature.runtime_error, channelTx);
|
|
||||||
const displayName = channelTx("displayName", "WeChat");
|
|
||||||
const enabledBusy = actionKey === `enable:${feature.name}`;
|
|
||||||
const disabledBusy = actionKey === `disable:${feature.name}`;
|
|
||||||
const channelBusy = enabledBusy || disabledBusy;
|
|
||||||
const channelChecked =
|
|
||||||
feature.runtime_status === "running" || feature.runtime_status === "starting";
|
|
||||||
const missingSupport = feature.enabled && !feature.installed;
|
|
||||||
const alwaysEnabled = feature.capabilities?.includes("always_enabled") ?? false;
|
|
||||||
const toggleChecked = alwaysEnabled || channelChecked;
|
|
||||||
const channelToggleDisabled =
|
|
||||||
alwaysEnabled
|
|
||||||
|| channelBusy
|
|
||||||
|| (!feature.install_supported && !feature.installed && !feature.enabled);
|
|
||||||
const [connectRequestId, setConnectRequestId] = useState(0);
|
|
||||||
const [visibleSecrets, setVisibleSecrets] = useState<Record<string, boolean>>({});
|
|
||||||
const [touchedFields, setTouchedFields] = useState<Set<string>>(() => new Set());
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [saveRevision, setSaveRevision] = useState(0);
|
|
||||||
const [attemptedRevision, setAttemptedRevision] = useState(0);
|
|
||||||
const [saveState, setSaveState] = useState<"idle" | "saved">("idle");
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
const configValuesKey = JSON.stringify(feature.config_values ?? {});
|
|
||||||
const setupFieldsKey = JSON.stringify(feature.setup?.fields ?? []);
|
|
||||||
const configuredFields = useMemo(
|
|
||||||
() => new Set(feature.configured_fields ?? []),
|
|
||||||
[feature.configured_fields],
|
|
||||||
);
|
|
||||||
const onLabel = tx("settings.values.on", "On");
|
|
||||||
const offLabel = tx("settings.values.off", "Off");
|
|
||||||
const setupFields = weixinSetupFields(
|
|
||||||
feature,
|
|
||||||
i18n.resolvedLanguage ?? i18n.language,
|
|
||||||
);
|
|
||||||
const primaryFields = localizeBooleanFields(setupFields.primary, onLabel, offLabel);
|
|
||||||
const advancedFields = localizeBooleanFields(setupFields.advanced, onLabel, offLabel);
|
|
||||||
const editableFields = [...primaryFields, ...advancedFields];
|
|
||||||
const docsUrl = docsUrlWithBase(chatAppGuideUrl("wechat"), chatAppsDocsUrl)
|
|
||||||
?? chatAppGuideUrl("wechat");
|
|
||||||
const [fieldValues, setFieldValues] = useState<Record<string, string>>(() =>
|
|
||||||
defaultChannelFieldValues(editableFields, feature.config_values),
|
|
||||||
);
|
|
||||||
const fieldValuesRef = useRef(fieldValues);
|
|
||||||
const touchedFieldsRef = useRef(touchedFields);
|
|
||||||
const editableFieldsRef = useRef(editableFields);
|
|
||||||
const saveContextRef = useRef({
|
|
||||||
token,
|
|
||||||
enabled: feature.enabled,
|
|
||||||
onFeaturesUpdate,
|
|
||||||
});
|
|
||||||
editableFieldsRef.current = editableFields;
|
|
||||||
saveContextRef.current = {
|
|
||||||
token,
|
|
||||||
enabled: feature.enabled,
|
|
||||||
onFeaturesUpdate,
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const nextValues = defaultChannelFieldValues(editableFields, feature.config_values);
|
|
||||||
for (const key of touchedFieldsRef.current) {
|
|
||||||
nextValues[key] = fieldValuesRef.current[key] ?? "";
|
|
||||||
}
|
|
||||||
fieldValuesRef.current = nextValues;
|
|
||||||
setFieldValues(nextValues);
|
|
||||||
setVisibleSecrets({});
|
|
||||||
}, [configValuesKey, setupFieldsKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (saveState !== "saved") return;
|
|
||||||
const timeout = window.setTimeout(() => setSaveState("idle"), 1500);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [saveState]);
|
|
||||||
|
|
||||||
const saveSettings = useCallback(async (
|
|
||||||
values: Record<string, string>,
|
|
||||||
savedFields: Set<string>,
|
|
||||||
) => {
|
|
||||||
const context = saveContextRef.current;
|
|
||||||
setSaving(true);
|
|
||||||
setSaveError(null);
|
|
||||||
setSaveState("idle");
|
|
||||||
try {
|
|
||||||
const payload = await configureChannel(
|
|
||||||
client,
|
|
||||||
"weixin",
|
|
||||||
channelValuesForSave(editableFieldsRef.current, values),
|
|
||||||
{ enable: context.enabled },
|
|
||||||
);
|
|
||||||
const remainingFields = new Set(touchedFieldsRef.current);
|
|
||||||
for (const key of savedFields) {
|
|
||||||
if (fieldValuesRef.current[key] === values[key]) remainingFields.delete(key);
|
|
||||||
}
|
|
||||||
touchedFieldsRef.current = remainingFields;
|
|
||||||
setTouchedFields(remainingFields);
|
|
||||||
setSaveState(remainingFields.size ? "idle" : "saved");
|
|
||||||
if (payload.nanobot_features) context.onFeaturesUpdate(payload.nanobot_features);
|
|
||||||
} catch (err) {
|
|
||||||
setSaveError((err as Error).message);
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}, [client]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!editableFields.length
|
|
||||||
|| !touchedFields.size
|
|
||||||
|| saving
|
|
||||||
|| saveRevision <= attemptedRevision
|
|
||||||
) return;
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
setAttemptedRevision(saveRevision);
|
|
||||||
void saveSettings(
|
|
||||||
{ ...fieldValuesRef.current },
|
|
||||||
new Set(touchedFieldsRef.current),
|
|
||||||
);
|
|
||||||
}, 500);
|
|
||||||
return () => window.clearTimeout(timeout);
|
|
||||||
}, [
|
|
||||||
attemptedRevision,
|
|
||||||
editableFields.length,
|
|
||||||
saveRevision,
|
|
||||||
saveSettings,
|
|
||||||
saving,
|
|
||||||
touchedFields.size,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const setFieldValue = (key: string, value: string) => {
|
|
||||||
if (fieldValuesRef.current[key] === value) return;
|
|
||||||
const nextValues = { ...fieldValuesRef.current, [key]: value };
|
|
||||||
const nextTouchedFields = new Set(touchedFieldsRef.current).add(key);
|
|
||||||
fieldValuesRef.current = nextValues;
|
|
||||||
touchedFieldsRef.current = nextTouchedFields;
|
|
||||||
setFieldValues(nextValues);
|
|
||||||
setTouchedFields(nextTouchedFields);
|
|
||||||
setSaveError(null);
|
|
||||||
setSaveState("idle");
|
|
||||||
setSaveRevision((current) => current + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAriaLabel = t("settings.channels.toggleChannel", {
|
|
||||||
name: displayName,
|
|
||||||
defaultValue: "{{name}} channel",
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside className="min-h-full rounded-[20px] bg-settings-surface p-5">
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div className="flex min-w-0 items-start gap-3">
|
|
||||||
<WeixinLogo showBrandLogos={showBrandLogos} />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<h3 className="truncate text-[18px] font-semibold leading-6 text-foreground">
|
|
||||||
{displayName}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
|
|
||||||
{channelTx("description", "Use nanobot from WeChat conversations.")}
|
|
||||||
</p>
|
|
||||||
{missingSupport && feature.install_supported ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={enabledBusy}
|
|
||||||
onClick={() => onAction("enable", feature.name)}
|
|
||||||
className="mt-2 h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
||||||
>
|
|
||||||
{enabledBusy ? (
|
|
||||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
||||||
) : (
|
|
||||||
<Plus className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
||||||
)}
|
|
||||||
{tx("settings.nanobotFeatures.installSupport", "Install support")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2 pt-1">
|
|
||||||
<WeixinStatusBadge status={feature.runtime_status}>
|
|
||||||
{weixinStatusLabel(feature, tx)}
|
|
||||||
</WeixinStatusBadge>
|
|
||||||
{channelBusy ? (
|
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" aria-hidden />
|
|
||||||
) : null}
|
|
||||||
<ToggleButton
|
|
||||||
checked={toggleChecked}
|
|
||||||
disabled={channelToggleDisabled}
|
|
||||||
ariaLabel={toggleAriaLabel}
|
|
||||||
label={toggleChecked ? onLabel : offLabel}
|
|
||||||
onChange={(checked) => {
|
|
||||||
if (checked && !channelChecked && feature.configured === false) {
|
|
||||||
setConnectRequestId((current) => current + 1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onAction(checked ? "enable" : "disable", feature.name);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{runtimeError ? (
|
|
||||||
<div className="mt-4 rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive">
|
|
||||||
{runtimeError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-4">
|
|
||||||
<WeixinConnectFlow
|
|
||||||
token={token}
|
|
||||||
feature={feature}
|
|
||||||
idleLabel={channelTx("setup.primaryAction", "Connect WeChat")}
|
|
||||||
connectRequestId={connectRequestId}
|
|
||||||
onFeaturesUpdate={onFeaturesUpdate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{primaryFields.length ? (
|
|
||||||
<CredentialForm
|
|
||||||
fields={primaryFields}
|
|
||||||
values={fieldValues}
|
|
||||||
configuredFields={configuredFields}
|
|
||||||
visibleSecrets={visibleSecrets}
|
|
||||||
onChange={setFieldValue}
|
|
||||||
onToggleSecret={(key) => {
|
|
||||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
|
||||||
}}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-live="polite"
|
|
||||||
aria-atomic="true"
|
|
||||||
className={cn(
|
|
||||||
"flex items-center justify-end gap-1.5 text-[11px] leading-4 text-muted-foreground",
|
|
||||||
!saving && saveState !== "saved" && "sr-only",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{saving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
||||||
{tx("settings.actions.saving", "Saving")}
|
|
||||||
</>
|
|
||||||
) : saveState === "saved" ? (
|
|
||||||
<>
|
|
||||||
<Check className="h-3 w-3" aria-hidden />
|
|
||||||
{tx("settings.channels.savedSettings", "Saved settings.")}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{saveError ? (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
className="rounded-[12px] border border-destructive/20 bg-destructive/5 px-3 py-2 text-[12px] leading-5 text-destructive"
|
|
||||||
>
|
|
||||||
{saveError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{advancedFields.length ? (
|
|
||||||
<details className="group text-[12px] leading-5 text-muted-foreground">
|
|
||||||
<summary className="cursor-pointer list-none text-[12px] font-semibold text-foreground">
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
{tx("settings.channels.advanced", "Advanced")}
|
|
||||||
<ChevronDown
|
|
||||||
className="h-3.5 w-3.5 transition-transform group-open:rotate-180"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div className="mt-3">
|
|
||||||
<CredentialForm
|
|
||||||
fields={advancedFields}
|
|
||||||
values={fieldValues}
|
|
||||||
configuredFields={configuredFields}
|
|
||||||
visibleSecrets={visibleSecrets}
|
|
||||||
onChange={setFieldValue}
|
|
||||||
onToggleSecret={(key) => {
|
|
||||||
setVisibleSecrets((current) => ({ ...current, [key]: !current[key] }));
|
|
||||||
}}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<WeixinGuideLink
|
|
||||||
url={docsUrl}
|
|
||||||
label={channelTx("setup.docsLabel", "Open WeChat setup")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function weixinSetupFields(
|
|
||||||
feature: NanobotFeatureInfo,
|
|
||||||
locale: string,
|
|
||||||
): { primary: ChannelConfigField[]; advanced: ChannelConfigField[] } {
|
|
||||||
const fields = feature.setup?.fields ?? [];
|
|
||||||
const fieldsByKey = new Map(fields.map((field) => [field.key, field]));
|
|
||||||
const messages = channelLocaleMessages("weixin", normalizeLocale(locale))?.setup;
|
|
||||||
const knownKeys = new Set<string>([
|
|
||||||
...WEIXIN_PRIMARY_FIELD_KEYS,
|
|
||||||
...WEIXIN_ADVANCED_FIELD_KEYS,
|
|
||||||
]);
|
|
||||||
const extraKeys = fields
|
|
||||||
.map((field) => field.key)
|
|
||||||
.filter((key) => !knownKeys.has(key));
|
|
||||||
const hydrate = (keys: readonly string[]) => keys.flatMap((key) => {
|
|
||||||
const field = fieldsByKey.get(key);
|
|
||||||
if (!field) return [];
|
|
||||||
const copy = messages?.fields?.[channelFieldMessageKey("weixin", key)];
|
|
||||||
return [weixinConfigField(field, copy)];
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
primary: hydrate(WEIXIN_PRIMARY_FIELD_KEYS),
|
|
||||||
advanced: hydrate([...WEIXIN_ADVANCED_FIELD_KEYS, ...extraKeys]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function weixinConfigField(
|
|
||||||
field: ChannelSetupContractField,
|
|
||||||
copy: { label: string; placeholder?: string; help?: string; choices?: Record<string, string> }
|
|
||||||
| undefined,
|
|
||||||
): ChannelConfigField {
|
|
||||||
const choices = field.kind === "bool" ? ["true", "false"] : field.choices;
|
|
||||||
return {
|
|
||||||
key: field.key,
|
|
||||||
label: copy?.label ?? fieldLabel(field.field),
|
|
||||||
placeholder: copy?.placeholder,
|
|
||||||
help: copy?.help,
|
|
||||||
secret: field.kind === "secret",
|
|
||||||
optional: !field.required,
|
|
||||||
inputType: field.kind === "int" ? "number" : undefined,
|
|
||||||
defaultValue: field.default_value,
|
|
||||||
options:
|
|
||||||
field.kind === "enum" || field.kind === "bool"
|
|
||||||
? choices.map((choice) => ({
|
|
||||||
value: choice,
|
|
||||||
label: copy?.choices?.[choice] ?? fieldLabel(choice),
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldLabel(value: string): string {
|
|
||||||
const spaced = value
|
|
||||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
||||||
.replace(/[_-]+/g, " ")
|
|
||||||
.trim();
|
|
||||||
return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeixinLogo({ showBrandLogos }: { showBrandLogos: boolean }) {
|
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
|
|
||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
|
||||||
if (showBrandLogos && logoUrl) {
|
|
||||||
return (
|
|
||||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-[12px] bg-background">
|
|
||||||
<img
|
|
||||||
src={logoUrl}
|
|
||||||
alt=""
|
|
||||||
decoding="async"
|
|
||||||
loading="lazy"
|
|
||||||
className="h-5.5 w-5.5 max-h-6 max-w-6 object-contain"
|
|
||||||
onLoad={onLogoLoad}
|
|
||||||
onError={onLogoError}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[12px] bg-background text-[11px] font-bold"
|
|
||||||
style={{ color: "#07C160" }}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
WX
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeixinGuideLink({ url, label }: { url: string; label: string }) {
|
|
||||||
const logoUrls = useMemo(() => logoFallbackUrls("https://weixin.qq.com/favicon.ico"), []);
|
|
||||||
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex max-w-full items-center gap-2 rounded-full bg-background/80 py-1 pl-1 pr-2.5 text-[11.5px] font-semibold text-foreground transition-colors hover:bg-background"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="grid h-5 w-5 shrink-0 place-items-center overflow-hidden rounded-full bg-muted/70 text-[9px] font-bold"
|
|
||||||
style={{ color: "#07C160" }}
|
|
||||||
aria-hidden
|
|
||||||
>
|
|
||||||
{logoUrl ? (
|
|
||||||
<img
|
|
||||||
src={logoUrl}
|
|
||||||
alt=""
|
|
||||||
decoding="async"
|
|
||||||
loading="lazy"
|
|
||||||
className="h-3.5 w-3.5 object-contain"
|
|
||||||
onLoad={onLogoLoad}
|
|
||||||
onError={onLogoError}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"WX"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
<ExternalLink className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden />
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WeixinStatusBadge({
|
|
||||||
children,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
status?: ChannelRuntimeStatus;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span className={cn(
|
|
||||||
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
|
|
||||||
status === "failed"
|
|
||||||
? "bg-destructive/10 text-destructive"
|
|
||||||
: status === "running"
|
|
||||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-200"
|
|
||||||
: "bg-muted/75 text-muted-foreground",
|
|
||||||
)}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function weixinStatusLabel(
|
|
||||||
feature: NanobotFeatureInfo,
|
|
||||||
tx: (key: string, fallback: string) => string,
|
|
||||||
): string {
|
|
||||||
if (feature.runtime_status === "failed") {
|
|
||||||
return tx("settings.channels.runtimeFailed", "Failed");
|
|
||||||
}
|
|
||||||
if (feature.runtime_status === "starting") {
|
|
||||||
return tx("settings.channels.runtimeStarting", "Starting");
|
|
||||||
}
|
|
||||||
if (feature.runtime_status === "running") return tx("settings.values.on", "On");
|
|
||||||
if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running");
|
|
||||||
return tx("settings.values.off", "Off");
|
|
||||||
}
|
|
||||||
|
|
||||||
function weixinRuntimeError(
|
|
||||||
error: string | undefined,
|
|
||||||
tx: (key: string, fallback: string) => string,
|
|
||||||
): string | undefined {
|
|
||||||
if (error === WEIXIN_AUTH_EXPIRED_MESSAGE) {
|
|
||||||
return tx("custom.expired", error);
|
|
||||||
}
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function localizeBooleanFields(
|
|
||||||
fields: ChannelConfigField[],
|
|
||||||
onLabel: string,
|
|
||||||
offLabel: string,
|
|
||||||
): ChannelConfigField[] {
|
|
||||||
return fields.map((field) => {
|
|
||||||
const values = new Set(field.options?.map((option) => option.value));
|
|
||||||
if (values.size !== 2 || !values.has("true") || !values.has("false")) return field;
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
options: field.options?.map((option) => ({
|
|
||||||
...option,
|
|
||||||
label: option.value === "true" ? onLabel : offLabel,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,8 @@ import type { ChannelUiContribution } from "@/channel-plugins/types";
|
|||||||
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
|
||||||
|
|
||||||
import { WeixinConnectFlow } from "./WeixinConnectFlow";
|
import { WeixinConnectFlow } from "./WeixinConnectFlow";
|
||||||
import {
|
|
||||||
WEIXIN_ADVANCED_FIELD_KEYS,
|
|
||||||
WEIXIN_PRIMARY_FIELD_KEYS,
|
|
||||||
WeixinPanel,
|
|
||||||
} from "./WeixinPanel";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
Panel: WeixinPanel,
|
|
||||||
ConnectFlow: WeixinConnectFlow,
|
ConnectFlow: WeixinConnectFlow,
|
||||||
canConnectBeforeConfigured: true,
|
canConnectBeforeConfigured: true,
|
||||||
aliases: {
|
aliases: {
|
||||||
@@ -24,8 +18,10 @@ export default {
|
|||||||
mode: "connect",
|
mode: "connect",
|
||||||
command: "nanobot channels login weixin",
|
command: "nanobot channels login weixin",
|
||||||
docsUrl: chatAppGuideUrl("wechat"),
|
docsUrl: chatAppGuideUrl("wechat"),
|
||||||
fields: WEIXIN_PRIMARY_FIELD_KEYS.map((key) => ({ key })),
|
manualFields: [
|
||||||
manualFields: WEIXIN_ADVANCED_FIELD_KEYS.map((key) => ({ key })),
|
{ key: "channels.weixin.allowFrom" },
|
||||||
|
{ key: "channels.weixin.token" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies ChannelUiContribution;
|
} satisfies ChannelUiContribution;
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Token",
|
"label": "Token",
|
||||||
"placeholder": "Saved by QR login"
|
"placeholder": "Saved by QR login"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Send progress" },
|
|
||||||
"sendToolHints": { "label": "Send tool hints" },
|
|
||||||
"streaming": { "label": "Use streaming API" },
|
|
||||||
"replyProgressMessages": { "label": "Send structured progress" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Structured progress limit" },
|
|
||||||
"contextMessageBudget": { "label": "Context message budget" },
|
|
||||||
"blockStreaming": { "label": "Send response blocks" },
|
|
||||||
"blockStreamingMinChars": { "label": "Minimum block size" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Block message limit" },
|
|
||||||
"baseUrl": { "label": "API URL" },
|
|
||||||
"cdnBaseUrl": { "label": "CDN URL" },
|
|
||||||
"routeTag": { "label": "Route tag" },
|
|
||||||
"stateDir": { "label": "State directory" },
|
|
||||||
"pollTimeout": { "label": "Poll timeout" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "Waiting for WeChat scan...",
|
"waiting": "Waiting for WeChat scan...",
|
||||||
"connected": "WeChat is connected.",
|
"connected": "WeChat is connected.",
|
||||||
"stopped": "WeChat login stopped.",
|
"stopped": "WeChat login stopped.",
|
||||||
"connecting": "Connecting...",
|
"connecting": "Connecting..."
|
||||||
"verifyTitle": "Verification required",
|
|
||||||
"verifyDescription": "Enter the number shown in WeChat to continue.",
|
|
||||||
"verifyMismatch": "That code did not match. Enter the new number shown in WeChat.",
|
|
||||||
"expired": "WeChat login expired. Scan again to reconnect.",
|
|
||||||
"failed": "Unable to connect WeChat. Try again.",
|
|
||||||
"verifyPlaceholder": "Code",
|
|
||||||
"verifySubmit": "Verify"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Token",
|
"label": "Token",
|
||||||
"placeholder": "Guardado al iniciar sesión por QR"
|
"placeholder": "Guardado al iniciar sesión por QR"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Enviar progreso" },
|
|
||||||
"sendToolHints": { "label": "Enviar indicaciones de herramientas" },
|
|
||||||
"streaming": { "label": "Usar API de streaming" },
|
|
||||||
"replyProgressMessages": { "label": "Enviar progreso estructurado" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Límite de progreso estructurado" },
|
|
||||||
"contextMessageBudget": { "label": "Presupuesto de mensajes por contexto" },
|
|
||||||
"blockStreaming": { "label": "Enviar respuestas por bloques" },
|
|
||||||
"blockStreamingMinChars": { "label": "Tamaño mínimo del bloque" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Límite de mensajes por bloques" },
|
|
||||||
"baseUrl": { "label": "URL de la API" },
|
|
||||||
"cdnBaseUrl": { "label": "URL de la CDN" },
|
|
||||||
"routeTag": { "label": "Etiqueta de ruta" },
|
|
||||||
"stateDir": { "label": "Directorio de estado" },
|
|
||||||
"pollTimeout": { "label": "Tiempo de espera de consulta" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "Esperando el escaneo de WeChat...",
|
"waiting": "Esperando el escaneo de WeChat...",
|
||||||
"connected": "WeChat está conectado.",
|
"connected": "WeChat está conectado.",
|
||||||
"stopped": "Inicio de WeChat detenido.",
|
"stopped": "Inicio de WeChat detenido.",
|
||||||
"connecting": "Conectando...",
|
"connecting": "Conectando..."
|
||||||
"verifyTitle": "Se requiere verificación",
|
|
||||||
"verifyDescription": "Introduce el número que aparece en WeChat para continuar.",
|
|
||||||
"verifyMismatch": "El código no coincide. Introduce el nuevo número que aparece en WeChat.",
|
|
||||||
"expired": "El inicio de sesión de WeChat caducó. Escanea de nuevo para volver a conectarte.",
|
|
||||||
"failed": "No se pudo conectar WeChat. Inténtalo de nuevo.",
|
|
||||||
"verifyPlaceholder": "Código",
|
|
||||||
"verifySubmit": "Verificar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Jeton",
|
"label": "Jeton",
|
||||||
"placeholder": "Enregistré après la connexion QR"
|
"placeholder": "Enregistré après la connexion QR"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Envoyer la progression" },
|
|
||||||
"sendToolHints": { "label": "Envoyer les indications d’outils" },
|
|
||||||
"streaming": { "label": "Utiliser l’API de streaming" },
|
|
||||||
"replyProgressMessages": { "label": "Envoyer la progression structurée" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Limite de progression structurée" },
|
|
||||||
"contextMessageBudget": { "label": "Budget de messages du contexte" },
|
|
||||||
"blockStreaming": { "label": "Envoyer la réponse par blocs" },
|
|
||||||
"blockStreamingMinChars": { "label": "Taille minimale d’un bloc" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Limite de messages par blocs" },
|
|
||||||
"baseUrl": { "label": "URL de l’API" },
|
|
||||||
"cdnBaseUrl": { "label": "URL du CDN" },
|
|
||||||
"routeTag": { "label": "Étiquette de routage" },
|
|
||||||
"stateDir": { "label": "Répertoire d’état" },
|
|
||||||
"pollTimeout": { "label": "Délai d’interrogation" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "En attente du scan WeChat...",
|
"waiting": "En attente du scan WeChat...",
|
||||||
"connected": "WeChat est connecté.",
|
"connected": "WeChat est connecté.",
|
||||||
"stopped": "Connexion WeChat arrêtée.",
|
"stopped": "Connexion WeChat arrêtée.",
|
||||||
"connecting": "Connexion...",
|
"connecting": "Connexion..."
|
||||||
"verifyTitle": "Vérification requise",
|
|
||||||
"verifyDescription": "Saisissez le nombre affiché dans WeChat pour continuer.",
|
|
||||||
"verifyMismatch": "Le code ne correspond pas. Saisissez le nouveau nombre affiché dans WeChat.",
|
|
||||||
"expired": "La connexion WeChat a expiré. Scannez à nouveau pour vous reconnecter.",
|
|
||||||
"failed": "Impossible de connecter WeChat. Réessayez.",
|
|
||||||
"verifyPlaceholder": "Code",
|
|
||||||
"verifySubmit": "Vérifier"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Token",
|
"label": "Token",
|
||||||
"placeholder": "Disimpan saat login QR"
|
"placeholder": "Disimpan saat login QR"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Kirim progres" },
|
|
||||||
"sendToolHints": { "label": "Kirim petunjuk alat" },
|
|
||||||
"streaming": { "label": "Gunakan API streaming" },
|
|
||||||
"replyProgressMessages": { "label": "Kirim progres terstruktur" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Batas progres terstruktur" },
|
|
||||||
"contextMessageBudget": { "label": "Anggaran pesan konteks" },
|
|
||||||
"blockStreaming": { "label": "Kirim respons per blok" },
|
|
||||||
"blockStreamingMinChars": { "label": "Ukuran blok minimum" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Batas pesan blok" },
|
|
||||||
"baseUrl": { "label": "URL API" },
|
|
||||||
"cdnBaseUrl": { "label": "URL CDN" },
|
|
||||||
"routeTag": { "label": "Tag rute" },
|
|
||||||
"stateDir": { "label": "Direktori status" },
|
|
||||||
"pollTimeout": { "label": "Batas waktu polling" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "Menunggu pemindaian WeChat...",
|
"waiting": "Menunggu pemindaian WeChat...",
|
||||||
"connected": "WeChat sudah terhubung.",
|
"connected": "WeChat sudah terhubung.",
|
||||||
"stopped": "Login WeChat dihentikan.",
|
"stopped": "Login WeChat dihentikan.",
|
||||||
"connecting": "Menghubungkan...",
|
"connecting": "Menghubungkan..."
|
||||||
"verifyTitle": "Verifikasi diperlukan",
|
|
||||||
"verifyDescription": "Masukkan angka yang ditampilkan di WeChat untuk melanjutkan.",
|
|
||||||
"verifyMismatch": "Kode tidak cocok. Masukkan angka baru yang ditampilkan di WeChat.",
|
|
||||||
"expired": "Login WeChat telah kedaluwarsa. Pindai lagi untuk menghubungkan kembali.",
|
|
||||||
"failed": "Tidak dapat menghubungkan WeChat. Coba lagi.",
|
|
||||||
"verifyPlaceholder": "Kode",
|
|
||||||
"verifySubmit": "Verifikasi"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "トークン",
|
"label": "トークン",
|
||||||
"placeholder": "QR ログインで保存"
|
"placeholder": "QR ログインで保存"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "進捗を送信" },
|
|
||||||
"sendToolHints": { "label": "ツールのヒントを送信" },
|
|
||||||
"streaming": { "label": "ストリーミング API を使用" },
|
|
||||||
"replyProgressMessages": { "label": "構造化された進捗を送信" },
|
|
||||||
"replyProgressMaxMessages": { "label": "構造化進捗の上限" },
|
|
||||||
"contextMessageBudget": { "label": "コンテキストのメッセージ予算" },
|
|
||||||
"blockStreaming": { "label": "応答をブロック単位で送信" },
|
|
||||||
"blockStreamingMinChars": { "label": "最小ブロックサイズ" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "ブロックメッセージの上限" },
|
|
||||||
"baseUrl": { "label": "API URL" },
|
|
||||||
"cdnBaseUrl": { "label": "CDN URL" },
|
|
||||||
"routeTag": { "label": "ルートタグ" },
|
|
||||||
"stateDir": { "label": "状態ディレクトリ" },
|
|
||||||
"pollTimeout": { "label": "ポーリングタイムアウト" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "WeChat のスキャンを待っています...",
|
"waiting": "WeChat のスキャンを待っています...",
|
||||||
"connected": "WeChat に接続しました。",
|
"connected": "WeChat に接続しました。",
|
||||||
"stopped": "WeChat ログインを停止しました。",
|
"stopped": "WeChat ログインを停止しました。",
|
||||||
"connecting": "接続中...",
|
"connecting": "接続中..."
|
||||||
"verifyTitle": "確認が必要です",
|
|
||||||
"verifyDescription": "WeChat に表示された数字を入力してください。",
|
|
||||||
"verifyMismatch": "コードが一致しません。WeChat に表示された新しい数字を入力してください。",
|
|
||||||
"expired": "WeChat のログイン期限が切れました。再接続するにはもう一度スキャンしてください。",
|
|
||||||
"failed": "WeChat に接続できません。もう一度お試しください。",
|
|
||||||
"verifyPlaceholder": "コード",
|
|
||||||
"verifySubmit": "確認"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "토큰",
|
"label": "토큰",
|
||||||
"placeholder": "QR 로그인으로 저장됨"
|
"placeholder": "QR 로그인으로 저장됨"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "진행 상황 보내기" },
|
|
||||||
"sendToolHints": { "label": "도구 힌트 보내기" },
|
|
||||||
"streaming": { "label": "스트리밍 API 사용" },
|
|
||||||
"replyProgressMessages": { "label": "구조화된 진행 상황 보내기" },
|
|
||||||
"replyProgressMaxMessages": { "label": "구조화된 진행 메시지 한도" },
|
|
||||||
"contextMessageBudget": { "label": "컨텍스트 메시지 예산" },
|
|
||||||
"blockStreaming": { "label": "응답을 블록으로 보내기" },
|
|
||||||
"blockStreamingMinChars": { "label": "최소 블록 크기" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "블록 메시지 한도" },
|
|
||||||
"baseUrl": { "label": "API URL" },
|
|
||||||
"cdnBaseUrl": { "label": "CDN URL" },
|
|
||||||
"routeTag": { "label": "경로 태그" },
|
|
||||||
"stateDir": { "label": "상태 디렉터리" },
|
|
||||||
"pollTimeout": { "label": "폴링 제한 시간" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "WeChat 스캔을 기다리는 중...",
|
"waiting": "WeChat 스캔을 기다리는 중...",
|
||||||
"connected": "WeChat이 연결되었습니다.",
|
"connected": "WeChat이 연결되었습니다.",
|
||||||
"stopped": "WeChat 로그인이 중지되었습니다.",
|
"stopped": "WeChat 로그인이 중지되었습니다.",
|
||||||
"connecting": "연결 중...",
|
"connecting": "연결 중..."
|
||||||
"verifyTitle": "인증 필요",
|
|
||||||
"verifyDescription": "계속하려면 WeChat에 표시된 숫자를 입력하세요.",
|
|
||||||
"verifyMismatch": "코드가 일치하지 않습니다. WeChat에 표시된 새 숫자를 입력하세요.",
|
|
||||||
"expired": "WeChat 로그인이 만료되었습니다. 다시 연결하려면 다시 스캔하세요.",
|
|
||||||
"failed": "WeChat에 연결할 수 없습니다. 다시 시도하세요.",
|
|
||||||
"verifyPlaceholder": "코드",
|
|
||||||
"verifySubmit": "인증"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Token",
|
"label": "Token",
|
||||||
"placeholder": "Salvo pelo login via QR"
|
"placeholder": "Salvo pelo login via QR"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Enviar progresso" },
|
|
||||||
"sendToolHints": { "label": "Enviar dicas de ferramentas" },
|
|
||||||
"streaming": { "label": "Usar API de streaming" },
|
|
||||||
"replyProgressMessages": { "label": "Enviar progresso estruturado" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Limite de progresso estruturado" },
|
|
||||||
"contextMessageBudget": { "label": "Orçamento de mensagens do contexto" },
|
|
||||||
"blockStreaming": { "label": "Enviar resposta em blocos" },
|
|
||||||
"blockStreamingMinChars": { "label": "Tamanho mínimo do bloco" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Limite de mensagens em blocos" },
|
|
||||||
"baseUrl": { "label": "URL da API" },
|
|
||||||
"cdnBaseUrl": { "label": "URL da CDN" },
|
|
||||||
"routeTag": { "label": "Etiqueta de rota" },
|
|
||||||
"stateDir": { "label": "Diretório de estado" },
|
|
||||||
"pollTimeout": { "label": "Tempo limite da consulta" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "Aguardando leitura do WeChat...",
|
"waiting": "Aguardando leitura do WeChat...",
|
||||||
"connected": "WeChat está conectado.",
|
"connected": "WeChat está conectado.",
|
||||||
"stopped": "Login do WeChat interrompido.",
|
"stopped": "Login do WeChat interrompido.",
|
||||||
"connecting": "Conectando...",
|
"connecting": "Conectando..."
|
||||||
"verifyTitle": "Verificação necessária",
|
|
||||||
"verifyDescription": "Digite o número exibido no WeChat para continuar.",
|
|
||||||
"verifyMismatch": "O código não corresponde. Digite o novo número exibido no WeChat.",
|
|
||||||
"expired": "O login do WeChat expirou. Escaneie novamente para reconectar.",
|
|
||||||
"failed": "Não foi possível conectar o WeChat. Tente novamente.",
|
|
||||||
"verifyPlaceholder": "Código",
|
|
||||||
"verifySubmit": "Verificar"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,21 +20,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "Token",
|
"label": "Token",
|
||||||
"placeholder": "Được lưu khi đăng nhập QR"
|
"placeholder": "Được lưu khi đăng nhập QR"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "Gửi tiến trình" },
|
|
||||||
"sendToolHints": { "label": "Gửi gợi ý công cụ" },
|
|
||||||
"streaming": { "label": "Sử dụng API phát trực tiếp" },
|
|
||||||
"replyProgressMessages": { "label": "Gửi tiến trình có cấu trúc" },
|
|
||||||
"replyProgressMaxMessages": { "label": "Giới hạn tiến trình có cấu trúc" },
|
|
||||||
"contextMessageBudget": { "label": "Ngân sách tin nhắn ngữ cảnh" },
|
|
||||||
"blockStreaming": { "label": "Gửi phản hồi theo khối" },
|
|
||||||
"blockStreamingMinChars": { "label": "Kích thước khối tối thiểu" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "Giới hạn tin nhắn theo khối" },
|
|
||||||
"baseUrl": { "label": "URL API" },
|
|
||||||
"cdnBaseUrl": { "label": "URL CDN" },
|
|
||||||
"routeTag": { "label": "Thẻ định tuyến" },
|
|
||||||
"stateDir": { "label": "Thư mục trạng thái" },
|
|
||||||
"pollTimeout": { "label": "Thời gian chờ thăm dò" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -44,13 +30,6 @@
|
|||||||
"waiting": "Đang chờ quét WeChat...",
|
"waiting": "Đang chờ quét WeChat...",
|
||||||
"connected": "WeChat đã kết nối.",
|
"connected": "WeChat đã kết nối.",
|
||||||
"stopped": "Đăng nhập WeChat đã dừng.",
|
"stopped": "Đăng nhập WeChat đã dừng.",
|
||||||
"connecting": "Đang kết nối...",
|
"connecting": "Đang kết nối..."
|
||||||
"verifyTitle": "Cần xác minh",
|
|
||||||
"verifyDescription": "Nhập số hiển thị trong WeChat để tiếp tục.",
|
|
||||||
"verifyMismatch": "Mã không khớp. Nhập số mới hiển thị trong WeChat.",
|
|
||||||
"expired": "Đăng nhập WeChat đã hết hạn. Hãy quét lại để kết nối lại.",
|
|
||||||
"failed": "Không thể kết nối WeChat. Hãy thử lại.",
|
|
||||||
"verifyPlaceholder": "Mã",
|
|
||||||
"verifySubmit": "Xác minh"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,21 +21,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "令牌",
|
"label": "令牌",
|
||||||
"placeholder": "二维码登录后自动保存"
|
"placeholder": "二维码登录后自动保存"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "发送进度消息" },
|
|
||||||
"sendToolHints": { "label": "发送工具提示" },
|
|
||||||
"streaming": { "label": "使用流式 API" },
|
|
||||||
"replyProgressMessages": { "label": "发送结构化进度" },
|
|
||||||
"replyProgressMaxMessages": { "label": "结构化进度消息上限" },
|
|
||||||
"contextMessageBudget": { "label": "上下文消息预算" },
|
|
||||||
"blockStreaming": { "label": "分块发送回复" },
|
|
||||||
"blockStreamingMinChars": { "label": "最小分块字符数" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "分块消息上限" },
|
|
||||||
"baseUrl": { "label": "API 地址" },
|
|
||||||
"cdnBaseUrl": { "label": "CDN 地址" },
|
|
||||||
"routeTag": { "label": "路由标签" },
|
|
||||||
"stateDir": { "label": "状态目录" },
|
|
||||||
"pollTimeout": { "label": "轮询超时" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -45,13 +31,6 @@
|
|||||||
"waiting": "正在等待微信扫码...",
|
"waiting": "正在等待微信扫码...",
|
||||||
"connected": "微信已连接。",
|
"connected": "微信已连接。",
|
||||||
"stopped": "微信登录已停止。",
|
"stopped": "微信登录已停止。",
|
||||||
"connecting": "正在连接...",
|
"connecting": "正在连接..."
|
||||||
"verifyTitle": "需要验证",
|
|
||||||
"verifyDescription": "输入手机微信中显示的数字以继续。",
|
|
||||||
"verifyMismatch": "验证码不匹配,请输入微信中显示的新数字。",
|
|
||||||
"expired": "微信登录已过期,请重新扫码连接。",
|
|
||||||
"failed": "无法连接微信,请重试。",
|
|
||||||
"verifyPlaceholder": "验证码",
|
|
||||||
"verifySubmit": "验证"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,21 +21,7 @@
|
|||||||
"token": {
|
"token": {
|
||||||
"label": "權杖",
|
"label": "權杖",
|
||||||
"placeholder": "二維碼登入後自動儲存"
|
"placeholder": "二維碼登入後自動儲存"
|
||||||
},
|
}
|
||||||
"sendProgress": { "label": "傳送進度訊息" },
|
|
||||||
"sendToolHints": { "label": "傳送工具提示" },
|
|
||||||
"streaming": { "label": "使用串流 API" },
|
|
||||||
"replyProgressMessages": { "label": "傳送結構化進度" },
|
|
||||||
"replyProgressMaxMessages": { "label": "結構化進度訊息上限" },
|
|
||||||
"contextMessageBudget": { "label": "上下文訊息預算" },
|
|
||||||
"blockStreaming": { "label": "分塊傳送回覆" },
|
|
||||||
"blockStreamingMinChars": { "label": "最小分塊字元數" },
|
|
||||||
"blockStreamingMaxMessages": { "label": "分塊訊息上限" },
|
|
||||||
"baseUrl": { "label": "API 位址" },
|
|
||||||
"cdnBaseUrl": { "label": "CDN 位址" },
|
|
||||||
"routeTag": { "label": "路由標籤" },
|
|
||||||
"stateDir": { "label": "狀態目錄" },
|
|
||||||
"pollTimeout": { "label": "輪詢逾時" }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"custom": {
|
"custom": {
|
||||||
@@ -45,13 +31,6 @@
|
|||||||
"waiting": "正在等待微信掃碼...",
|
"waiting": "正在等待微信掃碼...",
|
||||||
"connected": "微信已連接。",
|
"connected": "微信已連接。",
|
||||||
"stopped": "微信登入已停止。",
|
"stopped": "微信登入已停止。",
|
||||||
"connecting": "正在連接...",
|
"connecting": "正在連接..."
|
||||||
"verifyTitle": "需要驗證",
|
|
||||||
"verifyDescription": "輸入手機微信中顯示的數字以繼續。",
|
|
||||||
"verifyMismatch": "驗證碼不符,請輸入微信中顯示的新數字。",
|
|
||||||
"expired": "微信登入已過期,請重新掃碼連線。",
|
|
||||||
"failed": "無法連接微信,請重試。",
|
|
||||||
"verifyPlaceholder": "驗證碼",
|
|
||||||
"verifySubmit": "驗證"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -669,7 +669,6 @@ def _run_gateway(
|
|||||||
webui_runtime_surface=webui_runtime_surface,
|
webui_runtime_surface=webui_runtime_surface,
|
||||||
webui_runtime_capabilities=webui_runtime_capabilities,
|
webui_runtime_capabilities=webui_runtime_capabilities,
|
||||||
webui_skill_state_action=_webui_skill_state_action,
|
webui_skill_state_action=_webui_skill_state_action,
|
||||||
config_path=Path(config_path),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ from nanobot.cli.models import (
|
|||||||
)
|
)
|
||||||
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
|
from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars
|
||||||
from nanobot.config.schema import Config, ModelPresetConfig
|
from nanobot.config.schema import Config, ModelPresetConfig
|
||||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
|
||||||
|
|
||||||
console = Console()
|
console = Console()
|
||||||
|
|
||||||
@@ -1675,7 +1674,7 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
|
|||||||
login_oauth_interactive,
|
login_oauth_interactive,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import typer
|
|||||||
from rich.console import Console
|
from rich.console import Console
|
||||||
|
|
||||||
from nanobot import __logo__
|
from nanobot import __logo__
|
||||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nanobot.providers.registry import ProviderSpec
|
from nanobot.providers.registry import ProviderSpec
|
||||||
@@ -75,7 +74,7 @@ def _required_module_attribute(module_name: str, attribute: str) -> object:
|
|||||||
|
|
||||||
|
|
||||||
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
|
def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]:
|
||||||
"""Load the untyped OAuth client behind a typed boundary."""
|
"""Load the optional untyped OAuth client behind a typed boundary."""
|
||||||
return (
|
return (
|
||||||
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
|
cast(_GetOAuthToken, _required_module_attribute("oauth_cli_kit", "get_token")),
|
||||||
cast(
|
cast(
|
||||||
@@ -86,7 +85,7 @@ def _load_openai_oauth_client() -> tuple[_GetOAuthToken, _LoginOAuthInteractive]
|
|||||||
|
|
||||||
|
|
||||||
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
|
def _load_openai_oauth_storage() -> tuple[_OAuthProviderConfig, _FileTokenStorageFactory]:
|
||||||
"""Load the untyped OAuth storage API behind a typed boundary."""
|
"""Load the optional untyped OAuth storage API behind a typed boundary."""
|
||||||
return (
|
return (
|
||||||
cast(
|
cast(
|
||||||
_OAuthProviderConfig,
|
_OAuthProviderConfig,
|
||||||
@@ -242,7 +241,7 @@ def _login_openai_codex() -> None:
|
|||||||
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
|
f"[green]✓ Authenticated with OpenAI Codex[/green] [dim]{token.account_id}[/dim]"
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
@@ -251,7 +250,7 @@ def _logout_openai_codex() -> None:
|
|||||||
try:
|
try:
|
||||||
provider_config, storage_factory = _load_openai_oauth_storage()
|
provider_config, storage_factory = _load_openai_oauth_storage()
|
||||||
except ImportError:
|
except ImportError:
|
||||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
storage = storage_factory(token_filename=provider_config.token_filename)
|
storage = storage_factory(token_filename=provider_config.token_filename)
|
||||||
@@ -310,7 +309,7 @@ def _logout_github_copilot() -> None:
|
|||||||
try:
|
try:
|
||||||
from nanobot.providers.github_copilot_provider import get_storage
|
from nanobot.providers.github_copilot_provider import get_storage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
console.print(f"[red]{OAUTH_CLI_KIT_MISSING_MESSAGE}[/red]")
|
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
storage = get_storage()
|
storage = get_storage()
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from nanobot.cron.types import (
|
|||||||
CronSchedule,
|
CronSchedule,
|
||||||
CronStore,
|
CronStore,
|
||||||
)
|
)
|
||||||
|
from nanobot.utils.run_records import (
|
||||||
|
safe_run_record_name,
|
||||||
|
)
|
||||||
from nanobot.utils.run_records import (
|
from nanobot.utils.run_records import (
|
||||||
write_run_record as write_automation_run_record,
|
write_run_record as write_automation_run_record,
|
||||||
)
|
)
|
||||||
@@ -437,6 +440,10 @@ class CronService:
|
|||||||
tmp_path.unlink(missing_ok=True)
|
tmp_path.unlink(missing_ok=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_run_record_name(run_id: str) -> str:
|
||||||
|
return safe_run_record_name(run_id)
|
||||||
|
|
||||||
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
|
||||||
"""Write an internal audit record for one cron execution."""
|
"""Write an internal audit record for one cron execution."""
|
||||||
write_automation_run_record(self._run_records_dir, run_id, record)
|
write_automation_run_record(self._run_records_dir, run_id, record)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any, Mapping
|
|||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.session.automation_turns import (
|
from nanobot.session.automation_turns import (
|
||||||
AutomationTurnSpec,
|
AutomationTurnSpec,
|
||||||
|
automation_history_overrides_for_spec,
|
||||||
automation_trigger,
|
automation_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +63,11 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
|
|||||||
return value if isinstance(value, str) and value else None
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
|
||||||
|
"""Return session-history text/metadata overrides for a cron turn."""
|
||||||
|
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
|
||||||
|
|
||||||
|
|
||||||
def is_bound_cron_job(job: CronJob) -> bool:
|
def is_bound_cron_job(job: CronJob) -> bool:
|
||||||
"""True for session-bound cron jobs with complete delivery context."""
|
"""True for session-bound cron jobs with complete delivery context."""
|
||||||
payload = job.payload
|
payload = job.payload
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
"""Shared recovery guidance for OAuth dependency failures."""
|
|
||||||
|
|
||||||
OAUTH_CLI_KIT_MISSING_MESSAGE = (
|
|
||||||
"This nanobot installation is missing the required oauth-cli-kit package. "
|
|
||||||
"Reinstall or upgrade nanobot-ai using the same installation method."
|
|
||||||
)
|
|
||||||
@@ -586,7 +586,7 @@ class OpenAICompatProvider(LLMProvider):
|
|||||||
if os.environ.get("LANGFUSE_SECRET_KEY"):
|
if os.environ.get("LANGFUSE_SECRET_KEY"):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
"LANGFUSE_SECRET_KEY is set but langfuse is not installed; "
|
||||||
"run `nanobot plugins enable langfuse` to enable tracing"
|
"install with `pip install langfuse` to enable tracing"
|
||||||
)
|
)
|
||||||
from openai import AsyncOpenAI as _AsyncOpenAI
|
from openai import AsyncOpenAI as _AsyncOpenAI
|
||||||
AsyncOpenAI = _AsyncOpenAI
|
AsyncOpenAI = _AsyncOpenAI
|
||||||
|
|||||||
+12
-65
@@ -11,7 +11,7 @@ from copy import deepcopy
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
from typing import Any, Callable, Protocol, TypedDict, cast
|
||||||
from weakref import WeakValueDictionary
|
from weakref import WeakValueDictionary
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -36,7 +36,6 @@ from nanobot.utils.subagent_channel_display import scrub_subagent_announce_body
|
|||||||
FILE_MAX_MESSAGES = 2000
|
FILE_MAX_MESSAGES = 2000
|
||||||
SESSION_CACHE_MAX_SIZE = 128
|
SESSION_CACHE_MAX_SIZE = 128
|
||||||
MIN_REPLAY_MAX_MESSAGES = 120
|
MIN_REPLAY_MAX_MESSAGES = 120
|
||||||
MIN_COMPACTED_REPLAY_MESSAGES = 8
|
|
||||||
REPLAY_TOKENS_PER_MESSAGE = 100
|
REPLAY_TOKENS_PER_MESSAGE = 100
|
||||||
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
_MESSAGE_TIME_PREFIX_RE = re.compile(r"^\[Message Time: [^\]]+\]\n?")
|
||||||
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
_LOCAL_IMAGE_BREADCRUMB_RE = re.compile(r"^\[image: (?:/|~)[^\]]+\]\s*$")
|
||||||
@@ -147,15 +146,6 @@ class RetentionResult:
|
|||||||
already_consolidated_count: int
|
already_consolidated_count: int
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SessionPolicy:
|
|
||||||
"""Runtime rules that do not belong in durable session data."""
|
|
||||||
|
|
||||||
persist: bool = True
|
|
||||||
log_content: bool = True
|
|
||||||
disabled_tools: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Session:
|
class Session:
|
||||||
"""A conversation session."""
|
"""A conversation session."""
|
||||||
@@ -167,7 +157,6 @@ class Session:
|
|||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
last_consolidated: int = 0 # Number of messages already consolidated to files
|
last_consolidated: int = 0 # Number of messages already consolidated to files
|
||||||
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
provider_state: ProviderConversationState | None = field(default=None, repr=False)
|
||||||
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not isinstance(cast(object, self.metadata), dict):
|
if not isinstance(cast(object, self.metadata), dict):
|
||||||
@@ -202,37 +191,19 @@ class Session:
|
|||||||
extend_to_user: bool = False,
|
extend_to_user: bool = False,
|
||||||
include_runtime_context: bool = True,
|
include_runtime_context: bool = True,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return recent replayable messages for LLM input.
|
"""Return unconsolidated messages for LLM input.
|
||||||
|
|
||||||
History is sliced by message count first (``max_messages``), then by
|
History is sliced by message count first (``max_messages``), then by
|
||||||
token budget from the tail (``max_tokens``) when provided.
|
token budget from the tail (``max_tokens``) when provided.
|
||||||
"""
|
"""
|
||||||
replay_start = self.last_consolidated
|
unconsolidated = self.messages[self.last_consolidated:]
|
||||||
if replay_start:
|
|
||||||
# ``last_consolidated`` is archive progress, not a replay boundary.
|
|
||||||
# Keep a small raw suffix for continuity, extending back to the user
|
|
||||||
# that started an assistant/tool sequence when necessary.
|
|
||||||
recent_start = recent_message_start_index(
|
|
||||||
self.messages,
|
|
||||||
MIN_COMPACTED_REPLAY_MESSAGES,
|
|
||||||
extend_to_user=True,
|
|
||||||
)
|
|
||||||
replay_start = min(replay_start, recent_start)
|
|
||||||
|
|
||||||
replayable = self.messages[replay_start:]
|
|
||||||
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
max_messages = max_messages if max_messages > 0 else FILE_MAX_MESSAGES
|
||||||
unarchived_count = len(self.messages) - self.last_consolidated
|
|
||||||
if replay_start < self.last_consolidated and unarchived_count < max_messages:
|
|
||||||
# The archived replay suffix can exceed the nominal count when one
|
|
||||||
# tool-heavy turn spans the boundary. Preserve that complete turn.
|
|
||||||
start_idx = 0
|
|
||||||
else:
|
|
||||||
start_idx = recent_message_start_index(
|
start_idx = recent_message_start_index(
|
||||||
replayable,
|
unconsolidated,
|
||||||
max_messages,
|
max_messages,
|
||||||
extend_to_user=extend_to_user,
|
extend_to_user=extend_to_user,
|
||||||
)
|
)
|
||||||
sliced = replayable[start_idx:]
|
sliced = unconsolidated[start_idx:]
|
||||||
|
|
||||||
# Avoid starting mid-turn when possible, except for proactive
|
# Avoid starting mid-turn when possible, except for proactive
|
||||||
# assistant deliveries that the user may be replying to.
|
# assistant deliveries that the user may be replying to.
|
||||||
@@ -381,23 +352,16 @@ class Session:
|
|||||||
|
|
||||||
start_idx = max(0, len(self.messages) - max_messages)
|
start_idx = max(0, len(self.messages) - max_messages)
|
||||||
if extend_to_user:
|
if extend_to_user:
|
||||||
recovered_user = next(
|
start_idx = next(
|
||||||
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
|
(i for i in range(start_idx, -1, -1) if self.messages[i].get("role") == "user"),
|
||||||
None,
|
start_idx,
|
||||||
)
|
)
|
||||||
if recovered_user is not None:
|
|
||||||
start_idx = recovered_user
|
|
||||||
if start_idx > 0 and self.messages[start_idx - 1].get("_channel_delivery"):
|
|
||||||
start_idx -= 1
|
|
||||||
|
|
||||||
retained = self.messages[start_idx:]
|
retained = self.messages[start_idx:]
|
||||||
|
|
||||||
# Prefer starting at a user turn (or its preceding _channel_delivery) when one exists within the retained window.
|
# Prefer starting at a user turn when one exists within the retained window.
|
||||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||||
if first_user is not None:
|
if first_user is not None:
|
||||||
if first_user > 0 and retained[first_user - 1].get("_channel_delivery"):
|
|
||||||
retained = retained[first_user - 1:]
|
|
||||||
else:
|
|
||||||
retained = retained[first_user:]
|
retained = retained[first_user:]
|
||||||
elif not extend_to_user:
|
elif not extend_to_user:
|
||||||
# If the hard-capped tail is assistant/tool-only, anchor to the
|
# If the hard-capped tail is assistant/tool-only, anchor to the
|
||||||
@@ -1089,24 +1053,6 @@ class SessionManager:
|
|||||||
self._remember(session)
|
self._remember(session)
|
||||||
return session
|
return session
|
||||||
|
|
||||||
def get_or_create_transient(
|
|
||||||
self,
|
|
||||||
key: str,
|
|
||||||
*,
|
|
||||||
disabled_tools: Collection[str] = (),
|
|
||||||
) -> Session:
|
|
||||||
"""Return a fresh, non-persistent session without loading history."""
|
|
||||||
policy = SessionPolicy(
|
|
||||||
persist=False,
|
|
||||||
log_content=False,
|
|
||||||
disabled_tools=frozenset(disabled_tools),
|
|
||||||
)
|
|
||||||
session = self.get_cached(key)
|
|
||||||
if session is None or session.policy != policy:
|
|
||||||
session = Session(key=key, policy=policy)
|
|
||||||
self._remember(session)
|
|
||||||
return session
|
|
||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
return self._store.load(key)
|
return self._store.load(key)
|
||||||
|
|
||||||
@@ -1114,11 +1060,12 @@ class SessionManager:
|
|||||||
"""Attempt to recover a session from a corrupt JSONL file."""
|
"""Attempt to recover a session from a corrupt JSONL file."""
|
||||||
return self._jsonl_store.repair(key, path=path)
|
return self._jsonl_store.repair(key, path=path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _session_payload(session: Session) -> SessionPayload:
|
||||||
|
return JsonlSessionStore.session_payload(session)
|
||||||
|
|
||||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||||
"""Persist a session and retain it in the cache."""
|
"""Persist a session and retain it in the cache."""
|
||||||
if not session.policy.persist:
|
|
||||||
return
|
|
||||||
|
|
||||||
archiver = self._file_cap_archiver
|
archiver = self._file_cap_archiver
|
||||||
if archiver is not None:
|
if archiver is not None:
|
||||||
session.enforce_file_cap(
|
session.enforce_file_cap(
|
||||||
|
|||||||
@@ -334,12 +334,6 @@ def clear_websocket_turn_if_current(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def clear_websocket_turns(chat_id: str) -> None:
|
|
||||||
"""Forget every in-process turn projection for a discarded chat."""
|
|
||||||
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
|
||||||
_sync_websocket_turn_projection(chat_id)
|
|
||||||
|
|
||||||
|
|
||||||
def build_bus_progress_callback(
|
def build_bus_progress_callback(
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any, Mapping
|
|||||||
|
|
||||||
from nanobot.session.automation_turns import (
|
from nanobot.session.automation_turns import (
|
||||||
AutomationTurnSpec,
|
AutomationTurnSpec,
|
||||||
|
automation_history_overrides_for_spec,
|
||||||
automation_trigger,
|
automation_trigger,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,3 +50,13 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
|
|||||||
return None
|
return None
|
||||||
value = trigger.get("delivery_id")
|
value = trigger.get("delivery_id")
|
||||||
return value if isinstance(value, str) and value else None
|
return value if isinstance(value, str) and value else None
|
||||||
|
|
||||||
|
|
||||||
|
def local_trigger_history_overrides(
|
||||||
|
metadata: Mapping[str, Any] | None,
|
||||||
|
) -> tuple[str | None, dict[str, Any]]:
|
||||||
|
"""Return session-history text/metadata overrides for a local trigger turn."""
|
||||||
|
return automation_history_overrides_for_spec(
|
||||||
|
metadata,
|
||||||
|
LOCAL_TRIGGER_AUTOMATION_SPEC,
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,35 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.utils.helpers import detect_image_mime
|
from nanobot.utils.helpers import detect_image_mime
|
||||||
|
|
||||||
|
# Supported file extensions for text extraction
|
||||||
|
SUPPORTED_EXTENSIONS: set[str] = {
|
||||||
|
# Document formats
|
||||||
|
".pdf",
|
||||||
|
".docx",
|
||||||
|
".xlsx",
|
||||||
|
".pptx",
|
||||||
|
# Text formats
|
||||||
|
".txt",
|
||||||
|
".md",
|
||||||
|
".csv",
|
||||||
|
".json",
|
||||||
|
".xml",
|
||||||
|
".html",
|
||||||
|
".htm",
|
||||||
|
".log",
|
||||||
|
".yaml",
|
||||||
|
".yml",
|
||||||
|
".toml",
|
||||||
|
".ini",
|
||||||
|
".cfg",
|
||||||
|
# Image formats (for future OCR support)
|
||||||
|
".png",
|
||||||
|
".jpg",
|
||||||
|
".jpeg",
|
||||||
|
".gif",
|
||||||
|
".webp",
|
||||||
|
}
|
||||||
|
|
||||||
_MAX_TEXT_LENGTH = 200_000
|
_MAX_TEXT_LENGTH = 200_000
|
||||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||||
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
|
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
|
||||||
|
|||||||
@@ -274,6 +274,24 @@ def _text_line_count(text: str) -> int:
|
|||||||
return line_count if last_was_newline else line_count + 1
|
return line_count if last_was_newline else line_count + 1
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_file_edit_tracker(
|
||||||
|
*,
|
||||||
|
call_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
tool: Any,
|
||||||
|
workspace: Path | None,
|
||||||
|
params: dict[str, Any] | None,
|
||||||
|
) -> FileEditTracker | None:
|
||||||
|
trackers = prepare_file_edit_trackers(
|
||||||
|
call_id=call_id,
|
||||||
|
tool_name=tool_name,
|
||||||
|
tool=tool,
|
||||||
|
workspace=workspace,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
return trackers[0] if trackers else None
|
||||||
|
|
||||||
|
|
||||||
def prepare_file_edit_trackers(
|
def prepare_file_edit_trackers(
|
||||||
*,
|
*,
|
||||||
call_id: str,
|
call_id: str,
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ from __future__ import annotations
|
|||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, Iterable, cast
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from dulwich.objects import Blob, Commit, ObjectID, Tree
|
from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry
|
||||||
from dulwich.refs import Ref
|
from dulwich.refs import Ref
|
||||||
from dulwich.repo import Repo
|
from dulwich.repo import Repo
|
||||||
|
|
||||||
@@ -44,6 +45,25 @@ class CommitInfo:
|
|||||||
return f"{header}\n(no file changes)"
|
return f"{header}\n(no file changes)"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LineAge:
|
||||||
|
"""Age of a single line based on git blame."""
|
||||||
|
|
||||||
|
age_days: int # days since last modification
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_line_ages(
|
||||||
|
annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]],
|
||||||
|
) -> list[LineAge]:
|
||||||
|
"""Convert annotate results to per-line ages."""
|
||||||
|
now = datetime.now(tz=timezone.utc).date()
|
||||||
|
ages: list[LineAge] = []
|
||||||
|
for (commit, _tree_entry), _line_bytes in annotated:
|
||||||
|
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
|
||||||
|
ages.append(LineAge(age_days=(now - dt).days))
|
||||||
|
return ages
|
||||||
|
|
||||||
|
|
||||||
class GitStore:
|
class GitStore:
|
||||||
"""Git-backed version control for memory files."""
|
"""Git-backed version control for memory files."""
|
||||||
|
|
||||||
@@ -273,6 +293,33 @@ class GitStore:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise GitStoreError("Git log failed") from exc
|
raise GitStoreError("Git log failed") from exc
|
||||||
|
|
||||||
|
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||||
|
"""Compute the age of each line in a tracked file via git blame.
|
||||||
|
|
||||||
|
Returns one LineAge per line, in order.
|
||||||
|
Returns an empty list if the repo is not initialized or the file is
|
||||||
|
empty. Annotation failures raise :class:`GitStoreError`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.is_initialized():
|
||||||
|
return []
|
||||||
|
|
||||||
|
target = self._workspace / file_path
|
||||||
|
if not target.exists() or target.stat().st_size == 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from dulwich import porcelain
|
||||||
|
|
||||||
|
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
|
||||||
|
|
||||||
|
if not annotated:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return _compute_line_ages(annotated)
|
||||||
|
|
||||||
def diff_commits(self, sha1: str, sha2: str) -> str:
|
def diff_commits(self, sha1: str, sha2: str) -> str:
|
||||||
"""Show diff between two commits."""
|
"""Show diff between two commits."""
|
||||||
if not self.is_initialized():
|
if not self.is_initialized():
|
||||||
@@ -414,6 +461,13 @@ class GitStore:
|
|||||||
commit = cast("Commit", commit_obj)
|
commit = cast("Commit", commit_obj)
|
||||||
return cast("Tree", repo[commit.tree])
|
return cast("Tree", repo[commit.tree])
|
||||||
|
|
||||||
|
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
|
||||||
|
"""Find a commit by short SHA prefix match."""
|
||||||
|
for c in self.log(max_entries=max_entries):
|
||||||
|
if c.sha.startswith(short_sha):
|
||||||
|
return c
|
||||||
|
return None
|
||||||
|
|
||||||
def show_commit_diff(
|
def show_commit_diff(
|
||||||
self,
|
self,
|
||||||
short_sha: str,
|
short_sha: str,
|
||||||
|
|||||||
@@ -351,6 +351,18 @@ def timestamp() -> str:
|
|||||||
return datetime.now().isoformat()
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def current_time_str(timezone: str | None = None) -> str:
|
||||||
|
"""Return the current time string."""
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
tz = ZoneInfo(timezone) if timezone else None
|
||||||
|
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
|
||||||
|
offset = now.strftime("%z")
|
||||||
|
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
|
||||||
|
tz_name = timezone or (time.strftime("%Z") or "UTC")
|
||||||
|
return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})"
|
||||||
|
|
||||||
|
|
||||||
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
_TOOL_RESULT_PREVIEW_CHARS = 1200
|
||||||
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
Persisted subagent announcements mirror ``agent/subagent_announce.md``: header,
|
Persisted subagent announcements mirror ``agent/subagent_announce.md``: header,
|
||||||
full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only
|
full ``Task:`` assignment (model context), ``Result:``, and a trailing model-only
|
||||||
``Summarize…`` instruction. External channels (embedded WebUI, session previews)
|
``Summarize…`` instruction. External channels (embedded WebUI, session previews)
|
||||||
should show only the header plus a truncated result body.
|
should show only the header plus a truncated result body."""
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
# Cap the Result section so session previews stay readable; full text remains on
|
from typing import Any, cast
|
||||||
# disk for LLM replay.
|
|
||||||
|
# Cap Result section length so WebSocket session replay stays readable; full text
|
||||||
|
# remains on disk for LLM replay (we only mutate outgoing API copies in websocket).
|
||||||
_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800
|
_SUBAGENT_CHANNEL_RESULT_MAX_CHARS = 800
|
||||||
|
|
||||||
|
|
||||||
@@ -43,3 +44,16 @@ def scrub_subagent_announce_body(content: str) -> str:
|
|||||||
if header and body:
|
if header and body:
|
||||||
return f"{header}\n\n{body}"
|
return f"{header}\n\n{body}"
|
||||||
return header or body or stripped
|
return header or body or stripped
|
||||||
|
|
||||||
|
|
||||||
|
def scrub_subagent_messages_for_channel(messages: list[dict[str, Any]]) -> None:
|
||||||
|
"""Mutate message dicts in place when they carry ``subagent_result`` inject."""
|
||||||
|
for msg in messages:
|
||||||
|
if not isinstance(cast(object, msg), dict):
|
||||||
|
continue
|
||||||
|
if msg.get("injected_event") != "subagent_result":
|
||||||
|
continue
|
||||||
|
raw = msg.get("content")
|
||||||
|
if not isinstance(raw, str) or not raw.strip():
|
||||||
|
continue
|
||||||
|
msg["content"] = scrub_subagent_announce_body(raw)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
from nanobot.apps.cli import CliAppError, CliAppManager, CliAppsRuntimeConfig
|
||||||
@@ -90,8 +89,8 @@ def _query_first(query: QueryParams, key: str) -> str | None:
|
|||||||
return values[0] if values else None
|
return values[0] if values else None
|
||||||
|
|
||||||
|
|
||||||
def _manager(config_path: Path | None = None) -> CliAppManager:
|
def _manager() -> CliAppManager:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
cli_cfg = config.tools.cli_apps
|
cli_cfg = config.tools.cli_apps
|
||||||
return CliAppManager(
|
return CliAppManager(
|
||||||
workspace=config.workspace_path,
|
workspace=config.workspace_path,
|
||||||
@@ -103,12 +102,8 @@ def _manager(config_path: Path | None = None) -> CliAppManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def cli_apps_payload(
|
async def cli_apps_payload(*, installed_only: bool = False) -> dict[str, Any]:
|
||||||
*,
|
manager = _manager()
|
||||||
installed_only: bool = False,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
manager = _manager(config_path) if config_path is not None else _manager()
|
|
||||||
if installed_only:
|
if installed_only:
|
||||||
return manager.installed_payload()
|
return manager.installed_payload()
|
||||||
payload = manager.payload(cache_only=True)
|
payload = manager.payload(cache_only=True)
|
||||||
@@ -123,16 +118,11 @@ async def cli_apps_payload(
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def cli_apps_action(
|
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise CliAppError("missing CLI app name")
|
raise CliAppError("missing CLI app name")
|
||||||
manager = _manager(config_path) if config_path is not None else _manager()
|
manager = _manager()
|
||||||
if action == "install":
|
if action == "install":
|
||||||
return manager.install(name)
|
return manager.install(name)
|
||||||
if action == "update":
|
if action == "update":
|
||||||
|
|||||||
@@ -8,12 +8,9 @@ from typing import TYPE_CHECKING, Any, Callable
|
|||||||
|
|
||||||
from loguru import logger as default_logger
|
from loguru import logger as default_logger
|
||||||
|
|
||||||
from nanobot.config.loader import get_config_path
|
|
||||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
|
||||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||||
@@ -31,13 +28,11 @@ class GatewayServices:
|
|||||||
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
"""Explicit dependencies shared by WebSocket transport and HTTP routes."""
|
||||||
|
|
||||||
http: GatewayHTTPHandler
|
http: GatewayHTTPHandler
|
||||||
settings: WebUISettingsServices
|
|
||||||
tokens: GatewayTokenStore
|
tokens: GatewayTokenStore
|
||||||
media: WebUIMediaGateway
|
media: WebUIMediaGateway
|
||||||
ingress: WebUIIngressPolicy
|
ingress: WebUIIngressPolicy
|
||||||
transcripts: WebUITranscriptRecorder
|
transcripts: WebUITranscriptRecorder
|
||||||
workspaces: WebUIWorkspaceController
|
workspaces: WebUIWorkspaceController
|
||||||
temporary_chats: WebUITemporaryChats
|
|
||||||
session_manager: SessionManager | None
|
session_manager: SessionManager | None
|
||||||
cron_service: CronService | None
|
cron_service: CronService | None
|
||||||
local_trigger_store: LocalTriggerStore | None
|
local_trigger_store: LocalTriggerStore | None
|
||||||
@@ -53,7 +48,6 @@ def build_gateway_services(
|
|||||||
static_dist_path: Path | None,
|
static_dist_path: Path | None,
|
||||||
workspace_path: Path,
|
workspace_path: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
config_path: Path | None = None,
|
|
||||||
runtime_model_name: Callable[[], str | None] | None,
|
runtime_model_name: Callable[[], str | None] | None,
|
||||||
runtime_surface: str,
|
runtime_surface: str,
|
||||||
runtime_capabilities_overrides: dict[str, Any] | None,
|
runtime_capabilities_overrides: dict[str, Any] | None,
|
||||||
@@ -67,7 +61,6 @@ def build_gateway_services(
|
|||||||
skill_state_action: Callable[[set[str]], None] | None = None,
|
skill_state_action: Callable[[set[str]], None] | None = None,
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
settings = WebUISettingsServices.create(config_path or get_config_path())
|
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
ingress = DEFAULT_WEBUI_INGRESS_POLICY
|
||||||
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
minimum_frame_bytes = ingress.minimum_full_policy_frame_bytes()
|
||||||
@@ -89,12 +82,6 @@ def build_gateway_services(
|
|||||||
default_workspace=workspace_path,
|
default_workspace=workspace_path,
|
||||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||||
)
|
)
|
||||||
temporary_chats = WebUITemporaryChats(
|
|
||||||
bus=bus,
|
|
||||||
session_manager=session_manager,
|
|
||||||
workspaces=workspaces,
|
|
||||||
logger=logger,
|
|
||||||
)
|
|
||||||
http = GatewayHTTPHandler(
|
http = GatewayHTTPHandler(
|
||||||
config=config,
|
config=config,
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
@@ -107,7 +94,6 @@ def build_gateway_services(
|
|||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
settings=settings,
|
|
||||||
skills_workspace_path=workspace_path,
|
skills_workspace_path=workspace_path,
|
||||||
disabled_skills=disabled_skills,
|
disabled_skills=disabled_skills,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
@@ -121,13 +107,11 @@ def build_gateway_services(
|
|||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
http=http,
|
http=http,
|
||||||
settings=settings,
|
|
||||||
tokens=tokens,
|
tokens=tokens,
|
||||||
media=media,
|
media=media,
|
||||||
ingress=ingress,
|
ingress=ingress,
|
||||||
transcripts=transcripts,
|
transcripts=transcripts,
|
||||||
workspaces=workspaces,
|
workspaces=workspaces,
|
||||||
temporary_chats=temporary_chats,
|
|
||||||
session_manager=session_manager,
|
session_manager=session_manager,
|
||||||
cron_service=cron_service,
|
cron_service=cron_service,
|
||||||
local_trigger_store=local_trigger_store,
|
local_trigger_store=local_trigger_store,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from contextlib import suppress
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Literal, Mapping, cast
|
from typing import Any, Literal, Mapping, cast
|
||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
from nanobot.apps.protocol import app_manifest, compact_dict
|
from nanobot.apps.protocol import app_manifest, compact_dict
|
||||||
@@ -25,9 +25,6 @@ from nanobot.utils.helpers import ensure_dir
|
|||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.webui.settings_services import WebUISettingsConfig
|
|
||||||
|
|
||||||
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
_MCP_PRESET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
|
||||||
_SECRET_QUERY_RE = re.compile(
|
_SECRET_QUERY_RE = re.compile(
|
||||||
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
r"([?&](?:[^=&]*(?:api[_-]?key|token|secret|password|bearer)[^=&]*)=)[^&#\s]+",
|
||||||
@@ -844,9 +841,8 @@ def mcp_presets_payload(
|
|||||||
*,
|
*,
|
||||||
last_action: dict[str, Any] | None = None,
|
last_action: dict[str, Any] | None = None,
|
||||||
tool_preview: Mapping[str, list[str]] | None = None,
|
tool_preview: Mapping[str, list[str]] | None = None,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
known = _known_preset_names()
|
known = _known_preset_names()
|
||||||
preset_rows = [
|
preset_rows = [
|
||||||
_preset_payload(preset, config.tools.mcp_servers)
|
_preset_payload(preset, config.tools.mcp_servers)
|
||||||
@@ -932,11 +928,7 @@ async def _close_mcp_stacks(stacks: Mapping[str, Any]) -> None:
|
|||||||
await stack.aclose()
|
await stack.aclose()
|
||||||
|
|
||||||
|
|
||||||
async def mcp_presets_test_action(
|
async def mcp_presets_test_action(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Connect to an enabled MCP preset and report its tool surface."""
|
"""Connect to an enabled MCP preset and report its tool surface."""
|
||||||
from nanobot.agent.tools.mcp import connect_mcp_servers
|
from nanobot.agent.tools.mcp import connect_mcp_servers
|
||||||
|
|
||||||
@@ -949,22 +941,16 @@ async def mcp_presets_test_action(
|
|||||||
display_name = _display_name_for(name, preset)
|
display_name = _display_name_for(name, preset)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config = resolve_config_env_vars(
|
config = resolve_config_env_vars(load_config())
|
||||||
load_config(config_path) if config_path is not None else load_config(),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return mcp_presets_payload(
|
return mcp_presets_payload(last_action={
|
||||||
last_action={
|
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": _scrub_test_error(str(exc)),
|
"message": _scrub_test_error(str(exc)),
|
||||||
"error": _scrub_test_error(str(exc)),
|
"error": _scrub_test_error(str(exc)),
|
||||||
"tool_count": 0,
|
"tool_count": 0,
|
||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
},
|
})
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
cfg = config.tools.mcp_servers.get(name)
|
cfg = config.tools.mcp_servers.get(name)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
@@ -982,7 +968,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
if cfg.command and not _command_available(cfg.command):
|
if cfg.command and not _command_available(cfg.command):
|
||||||
last_action = {
|
last_action = {
|
||||||
@@ -993,7 +979,7 @@ async def mcp_presets_test_action(
|
|||||||
"tool_names": [],
|
"tool_names": [],
|
||||||
"checked_at": _checked_at(),
|
"checked_at": _checked_at(),
|
||||||
}
|
}
|
||||||
return mcp_presets_payload(last_action=last_action, config_path=config_path)
|
return mcp_presets_payload(last_action=last_action)
|
||||||
|
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks: dict[str, Any] = {}
|
stacks: dict[str, Any] = {}
|
||||||
@@ -1054,11 +1040,7 @@ async def mcp_presets_test_action(
|
|||||||
|
|
||||||
tool_names = last_action.get("tool_names", [])
|
tool_names = last_action.get("tool_names", [])
|
||||||
preview = {name: tool_names} if tool_names else None
|
preview = {name: tool_names} if tool_names else None
|
||||||
return mcp_presets_payload(
|
return mcp_presets_payload(last_action=last_action, tool_preview=preview)
|
||||||
last_action=last_action,
|
|
||||||
tool_preview=preview,
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
def _parse_json_value(raw: str | None, *, fallback: Any) -> Any:
|
||||||
@@ -1239,35 +1221,24 @@ def _import_mcp_servers(raw_json: str | None) -> dict[str, MCPServerConfig]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def custom_mcp_action(
|
def custom_mcp_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
config = load_config()
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
|
||||||
if action == "custom":
|
if action == "custom":
|
||||||
name, cfg = _custom_server_from_query(query)
|
name, cfg = _custom_server_from_query(query)
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
last_action=_server_action_message(action, name),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
if action in {"import", "import-cursor"}:
|
if action in {"import", "import-cursor"}:
|
||||||
servers = _import_mcp_servers(_query_first(query, "config"))
|
servers = _import_mcp_servers(_query_first(query, "config"))
|
||||||
config.tools.mcp_servers.update(servers)
|
config.tools.mcp_servers.update(servers)
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action={
|
||||||
last_action={
|
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Imported {len(servers)} MCP server(s).",
|
"message": f"Imported {len(servers)} MCP server(s).",
|
||||||
},
|
})
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1278,40 +1249,29 @@ def custom_mcp_action(
|
|||||||
raise McpPresetError("unknown MCP server", status=404)
|
raise McpPresetError("unknown MCP server", status=404)
|
||||||
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
cfg.enabled_tools = _parse_enabled_tools(_query_first(query, "enabled_tools"))
|
||||||
config.tools.mcp_servers[name] = cfg
|
config.tools.mcp_servers[name] = cfg
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_server_action_message(action, name))
|
||||||
last_action=_server_action_message(action, name),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
raise McpPresetError(f"unknown MCP action '{action}'", status=404)
|
||||||
|
|
||||||
|
|
||||||
def mcp_presets_action(
|
def mcp_presets_action(action: str, query: QueryParams) -> dict[str, Any]:
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
raise McpPresetError("missing MCP preset name")
|
raise McpPresetError("missing MCP preset name")
|
||||||
preset = _preset_by_name_optional(name)
|
preset = _preset_by_name_optional(name)
|
||||||
|
|
||||||
config = load_config(config_path) if config_path is not None else load_config()
|
config = load_config()
|
||||||
existing = config.tools.mcp_servers.get(name)
|
existing = config.tools.mcp_servers.get(name)
|
||||||
|
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise McpPresetError("unknown MCP preset", status=404)
|
raise McpPresetError("unknown MCP preset", status=404)
|
||||||
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
config.tools.mcp_servers[preset.name] = _materialize_server(preset, query, existing)
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=_action_message(action, preset))
|
||||||
last_action=_action_message(action, preset),
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1327,7 +1287,7 @@ def mcp_presets_action(
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
cleanup_error = str(exc)
|
cleanup_error = str(exc)
|
||||||
del config.tools.mcp_servers[name]
|
del config.tools.mcp_servers[name]
|
||||||
save_config(config, config_path)
|
save_config(config)
|
||||||
last_action = (
|
last_action = (
|
||||||
_action_message(action, preset)
|
_action_message(action, preset)
|
||||||
if preset is not None
|
if preset is not None
|
||||||
@@ -1343,10 +1303,7 @@ def mcp_presets_action(
|
|||||||
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
f"{last_action['message']} Could not remove managed runtime files: {cleanup_error}"
|
||||||
)
|
)
|
||||||
last_action["verification_failed"] = ["managed_paths_absent"]
|
last_action["verification_failed"] = ["managed_paths_absent"]
|
||||||
payload = mcp_presets_payload(
|
payload = mcp_presets_payload(last_action=last_action)
|
||||||
last_action=last_action,
|
|
||||||
config_path=config_path,
|
|
||||||
)
|
|
||||||
payload["requires_restart"] = True
|
payload["requires_restart"] = True
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -1382,21 +1339,13 @@ async def mcp_presets_settings_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
reload_mcp: McpReload | None = None,
|
reload_mcp: McpReload | None = None,
|
||||||
config: WebUISettingsConfig | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
"""Run a WebUI MCP preset action and hot-reload the agent when config changes."""
|
||||||
config_path = config.path if config is not None else None
|
|
||||||
if action is None:
|
if action is None:
|
||||||
return mcp_presets_payload(config_path=config_path)
|
return mcp_presets_payload()
|
||||||
if action == "test":
|
if action == "test":
|
||||||
return await mcp_presets_test_action(query, config_path=config_path)
|
return await mcp_presets_test_action(query)
|
||||||
if config is not None:
|
if action in _CUSTOM_ACTIONS:
|
||||||
operation = custom_mcp_action if action in _CUSTOM_ACTIONS else mcp_presets_action
|
|
||||||
payload = await asyncio.to_thread(
|
|
||||||
config.run_serialized,
|
|
||||||
lambda path: operation(action, query, config_path=path),
|
|
||||||
)
|
|
||||||
elif action in _CUSTOM_ACTIONS:
|
|
||||||
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
payload = await asyncio.to_thread(custom_mcp_action, action, query)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
payload = await asyncio.to_thread(mcp_presets_action, action, query)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import shutil
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, cast
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
@@ -32,6 +32,7 @@ from nanobot.webui.http_utils import (
|
|||||||
|
|
||||||
MediaDirProvider = Callable[[str | None], Path]
|
MediaDirProvider = Callable[[str | None], Path]
|
||||||
SignedMediaPath = Callable[[Path], dict[str, str] | None]
|
SignedMediaPath = Callable[[Path], dict[str, str] | None]
|
||||||
|
SignedMediaUrl = Callable[[Path], str | None]
|
||||||
|
|
||||||
|
|
||||||
def b64url_encode(data: bytes) -> str:
|
def b64url_encode(data: bytes) -> str:
|
||||||
@@ -189,6 +190,37 @@ def signed_media_attachments(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def attach_signed_media_urls(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
sign_path: SignedMediaUrl,
|
||||||
|
) -> None:
|
||||||
|
"""Replace raw media path lists in a WebUI session payload with signed URLs."""
|
||||||
|
messages = payload.get("messages")
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return
|
||||||
|
raw_messages = cast(list[Any], messages)
|
||||||
|
for msg in raw_messages:
|
||||||
|
if not isinstance(msg, dict):
|
||||||
|
continue
|
||||||
|
message = cast(dict[str, Any], msg)
|
||||||
|
media = message.get("media")
|
||||||
|
if not isinstance(media, list) or not media:
|
||||||
|
continue
|
||||||
|
media_entries = cast(list[Any], media)
|
||||||
|
urls: list[dict[str, str]] = []
|
||||||
|
for entry in media_entries:
|
||||||
|
if not isinstance(entry, str) or not entry:
|
||||||
|
continue
|
||||||
|
signed = sign_path(Path(entry))
|
||||||
|
if signed is None:
|
||||||
|
continue
|
||||||
|
urls.append({"url": signed, "name": Path(entry).name})
|
||||||
|
if urls:
|
||||||
|
message["media_urls"] = urls
|
||||||
|
message.pop("media", None)
|
||||||
|
|
||||||
|
|
||||||
def serve_signed_media(
|
def serve_signed_media(
|
||||||
sig: str,
|
sig: str,
|
||||||
payload: str,
|
payload: str,
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ from nanobot.webui.attachment_ingress import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.ingress_policy import AttachmentIngressLimits
|
from nanobot.webui.ingress_policy import AttachmentIngressLimits
|
||||||
from nanobot.webui.media_api import (
|
from nanobot.webui.media_api import (
|
||||||
|
attach_signed_media_urls,
|
||||||
serve_signed_media,
|
serve_signed_media,
|
||||||
|
sign_media_path,
|
||||||
sign_or_stage_media_path,
|
sign_or_stage_media_path,
|
||||||
signed_media_attachments,
|
signed_media_attachments,
|
||||||
)
|
)
|
||||||
@@ -70,6 +72,13 @@ class WebUIMediaGateway:
|
|||||||
media_dir=self._media_dir,
|
media_dir=self._media_dir,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def sign_media_path(self, abs_path: Path) -> str | None:
|
||||||
|
return sign_media_path(
|
||||||
|
abs_path,
|
||||||
|
secret=self.secret,
|
||||||
|
media_dir=self._media_dir,
|
||||||
|
)
|
||||||
|
|
||||||
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
|
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
|
||||||
return sign_or_stage_media_path(
|
return sign_or_stage_media_path(
|
||||||
path,
|
path,
|
||||||
@@ -90,6 +99,9 @@ class WebUIMediaGateway:
|
|||||||
sign_path=self.sign_or_stage_media_path,
|
sign_path=self.sign_or_stage_media_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def augment_media_urls(self, payload: dict[str, Any]) -> None:
|
||||||
|
attach_signed_media_urls(payload, sign_path=self.sign_media_path)
|
||||||
|
|
||||||
def augment_transcript_media(self, paths: list[str]) -> list[dict[str, Any]]:
|
def augment_transcript_media(self, paths: list[str]) -> list[dict[str, Any]]:
|
||||||
return signed_media_attachments(
|
return signed_media_attachments(
|
||||||
paths,
|
paths,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Nanobot optional feature helpers for WebUI Settings."""
|
"""Nanobot optional feature helpers for WebUI Settings."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
@@ -16,14 +15,9 @@ from nanobot.webui.http_utils import query_first
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
def nanobot_features_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
def nanobot_features_payload() -> dict[str, Any]:
|
||||||
if config_path is None:
|
|
||||||
return optional_features_payload()
|
return optional_features_payload()
|
||||||
|
|
||||||
from nanobot.config.loader import load_config
|
|
||||||
|
|
||||||
return optional_features_payload(config=load_config(config_path))
|
|
||||||
|
|
||||||
|
|
||||||
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
def nanobot_feature_instance_target(query: QueryParams) -> str | None:
|
||||||
"""Preserve the difference between a global action and an explicit instance."""
|
"""Preserve the difference between a global action and an explicit instance."""
|
||||||
@@ -38,19 +32,13 @@ def nanobot_features_action(
|
|||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
*,
|
*,
|
||||||
allow_install: bool = True,
|
allow_install: bool = True,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
name = (query_first(query, "name") or "").strip()
|
name = (query_first(query, "name") or "").strip()
|
||||||
instance_id = nanobot_feature_instance_target(query)
|
instance_id = nanobot_feature_instance_target(query)
|
||||||
if not name:
|
if not name:
|
||||||
raise OptionalFeatureError("missing feature name")
|
raise OptionalFeatureError("missing feature name")
|
||||||
if action == "enable":
|
if action == "enable":
|
||||||
return enable_optional_feature(
|
return enable_optional_feature(name, allow_install=allow_install, instance_id=instance_id)
|
||||||
name,
|
|
||||||
config_path=config_path,
|
|
||||||
allow_install=allow_install,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
if action == "disable":
|
if action == "disable":
|
||||||
try:
|
try:
|
||||||
plugin = load_channel_plugin(name)
|
plugin = load_channel_plugin(name)
|
||||||
@@ -62,9 +50,5 @@ def nanobot_features_action(
|
|||||||
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
f"Use `nanobot plugins disable {name}` from a terminal if you need to disable it.",
|
||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
return disable_optional_feature(
|
return disable_optional_feature(name, instance_id=instance_id)
|
||||||
name,
|
|
||||||
config_path=config_path,
|
|
||||||
instance_id=instance_id,
|
|
||||||
)
|
|
||||||
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
raise OptionalFeatureError(f"unknown feature action '{action}'", status=404)
|
||||||
|
|||||||
+148
-174
@@ -4,7 +4,7 @@ The WebSocket channel owns transport/authentication. This module owns the
|
|||||||
settings payload shape and the allowlisted config mutations exposed to WebUI.
|
settings payload shape and the allowlisted config mutations exposed to WebUI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# oauth-cli-kit does not publish type stubs.
|
# oauth-cli-kit is an optional dependency and does not publish type stubs.
|
||||||
# pyright: reportMissingTypeStubs=false
|
# pyright: reportMissingTypeStubs=false
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,11 +14,11 @@ import math
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from typing import Any, Literal, cast
|
||||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -36,7 +36,6 @@ from nanobot.providers.image_generation import (
|
|||||||
get_image_gen_provider,
|
get_image_gen_provider,
|
||||||
image_gen_provider_names,
|
image_gen_provider_names,
|
||||||
)
|
)
|
||||||
from nanobot.providers.oauth_guidance import OAUTH_CLI_KIT_MISSING_MESSAGE
|
|
||||||
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
from nanobot.providers.registry import PROVIDERS, create_dynamic_spec, find_by_name
|
||||||
from nanobot.security.network import is_loopback_host
|
from nanobot.security.network import is_loopback_host
|
||||||
from nanobot.security.workspace_access import workspace_sandbox_status
|
from nanobot.security.workspace_access import workspace_sandbox_status
|
||||||
@@ -49,9 +48,6 @@ from nanobot.webui.workspaces import (
|
|||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
RuntimeSurface = Literal["browser", "native"]
|
RuntimeSurface = Literal["browser", "native"]
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.webui.settings_services import WebUIOAuthFlowRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def _version_payload() -> dict[str, Any]:
|
def _version_payload() -> dict[str, Any]:
|
||||||
"""Return version info for the settings payload."""
|
"""Return version info for the settings payload."""
|
||||||
@@ -136,6 +132,9 @@ _IMAGE_GENERATION_ASPECT_RATIOS = {
|
|||||||
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
_CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576}
|
||||||
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
_OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"}
|
||||||
_WEBUI_OAUTH_TIMEOUT_S = 600
|
_WEBUI_OAUTH_TIMEOUT_S = 600
|
||||||
|
_WEBUI_OAUTH_MAX_FLOWS = 8
|
||||||
|
_webui_oauth_flows: dict[str, tuple[str, Any]] = {}
|
||||||
|
_webui_oauth_flows_lock = threading.Lock()
|
||||||
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
_MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+")
|
||||||
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||||
|
|
||||||
@@ -148,21 +147,6 @@ class WebUISettingsError(ValueError):
|
|||||||
self.status = status
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
def _load_settings_config(config_path: Path | None) -> Config:
|
|
||||||
return load_config(config_path) if config_path is not None else load_config()
|
|
||||||
|
|
||||||
|
|
||||||
def _save_settings_config(config: Config, config_path: Path | None) -> None:
|
|
||||||
if config_path is None:
|
|
||||||
save_config(config)
|
|
||||||
else:
|
|
||||||
save_config(config, config_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _settings_config_path(config_path: Path | None) -> Path:
|
|
||||||
return config_path if config_path is not None else get_config_path()
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
def _normalize_surface(surface: str | None) -> RuntimeSurface:
|
||||||
return "native" if surface in {"native", "desktop"} else "browser"
|
return "native" if surface in {"native", "desktop"} else "browser"
|
||||||
|
|
||||||
@@ -779,11 +763,7 @@ def _extract_model_rows(body: Any) -> list[dict[str, Any]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def provider_models_payload(
|
def provider_models_payload(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
"""Fetch an OpenAI-compatible provider's model list for Settings.
|
||||||
|
|
||||||
The result is advisory only: users can always type a custom model id. This
|
The result is advisory only: users can always type a custom model id. This
|
||||||
@@ -794,7 +774,7 @@ def provider_models_payload(
|
|||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1136,9 +1116,8 @@ def settings_payload(
|
|||||||
runtime_capability_overrides: dict[str, Any] | None = None,
|
runtime_capability_overrides: dict[str, Any] | None = None,
|
||||||
restart_required_sections: list[str] | None = None,
|
restart_required_sections: list[str] | None = None,
|
||||||
apply_state: dict[str, Any] | None = None,
|
apply_state: dict[str, Any] | None = None,
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
active_preset_name = defaults.model_preset or "default"
|
active_preset_name = defaults.model_preset or "default"
|
||||||
effective_preset = config.resolve_preset()
|
effective_preset = config.resolve_preset()
|
||||||
@@ -1319,7 +1298,7 @@ def settings_payload(
|
|||||||
"providers": _transcription_provider_rows(config),
|
"providers": _transcription_provider_rows(config),
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"config_path": str(_settings_config_path(config_path).expanduser()),
|
"config_path": str(get_config_path().expanduser()),
|
||||||
"workspace_path": str(config.workspace_path),
|
"workspace_path": str(config.workspace_path),
|
||||||
"gateway_host": config.gateway.host,
|
"gateway_host": config.gateway.host,
|
||||||
"gateway_port": config.gateway.port,
|
"gateway_port": config.gateway.port,
|
||||||
@@ -1361,18 +1340,14 @@ def settings_payload(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def settings_usage_payload(*, config_path: Path | None = None) -> dict[str, Any]:
|
def settings_usage_payload() -> dict[str, Any]:
|
||||||
"""Return the lightweight token usage slice for Overview refreshes."""
|
"""Return the lightweight token usage slice for Overview refreshes."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
return token_usage_payload(timezone_name=config.agents.defaults.timezone)
|
||||||
|
|
||||||
|
|
||||||
def update_agent_settings(
|
def update_agent_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
changed = False
|
changed = False
|
||||||
restart_required = False
|
restart_required = False
|
||||||
@@ -1449,15 +1424,11 @@ def update_agent_settings(
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def create_model_configuration(
|
def create_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
label = (_query_first_alias(query, "label", "displayName") or "").strip()
|
||||||
raw_name = (_query_first(query, "name") or label).strip()
|
raw_name = (_query_first(query, "name") or label).strip()
|
||||||
model = (_query_first(query, "model") or "").strip()
|
model = (_query_first(query, "model") or "").strip()
|
||||||
@@ -1471,7 +1442,7 @@ def create_model_configuration(
|
|||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
name = _model_configuration_slug(raw_name or label)
|
name = _model_configuration_slug(raw_name or label)
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if name in config.model_presets:
|
if name in config.model_presets:
|
||||||
raise WebUISettingsError("configuration already exists", status=409)
|
raise WebUISettingsError("configuration already exists", status=409)
|
||||||
_validate_configured_provider(config, provider)
|
_validate_configured_provider(config, provider)
|
||||||
@@ -1504,22 +1475,18 @@ def create_model_configuration(
|
|||||||
temperature=temperature if temperature is not None else base.temperature,
|
temperature=temperature if temperature is not None else base.temperature,
|
||||||
reasoning_effort=reasoning_effort,
|
reasoning_effort=reasoning_effort,
|
||||||
)
|
)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
payload = settings_payload(config_path=config_path)
|
payload = settings_payload()
|
||||||
payload["created_model_preset"] = name
|
payload["created_model_preset"] = name
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_model_configuration(
|
def update_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
preset = config.model_presets.get(name)
|
preset = config.model_presets.get(name)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
@@ -1586,15 +1553,11 @@ def update_model_configuration(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_model_call_order(
|
def update_model_call_order(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
raw_order = _query_first_alias(query, "order", "presetNames")
|
raw_order = _query_first_alias(query, "order", "presetNames")
|
||||||
if raw_order is None:
|
if raw_order is None:
|
||||||
raise WebUISettingsError("model call order is required")
|
raise WebUISettingsError("model call order is required")
|
||||||
@@ -1616,7 +1579,7 @@ def update_model_call_order(
|
|||||||
cast(str, name).strip()
|
cast(str, name).strip()
|
||||||
for name in cast(list[object], order)
|
for name in cast(list[object], order)
|
||||||
]
|
]
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
_, editable = _model_call_order_state(config)
|
_, editable = _model_call_order_state(config)
|
||||||
if not editable:
|
if not editable:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError(
|
||||||
@@ -1635,17 +1598,13 @@ def update_model_call_order(
|
|||||||
):
|
):
|
||||||
defaults.model_preset = normalized_order[0]
|
defaults.model_preset = normalized_order[0]
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def migrate_model_configurations(
|
def migrate_model_configurations(_query: QueryParams | None = None) -> dict[str, Any]:
|
||||||
_query: QueryParams | None = None,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Materialize legacy primary/inline model settings as named presets."""
|
"""Materialize legacy primary/inline model settings as named presets."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
primary = config.resolve_preset()
|
primary = config.resolve_preset()
|
||||||
created: list[str] = []
|
created: list[str] = []
|
||||||
@@ -1698,20 +1657,16 @@ def migrate_model_configurations(
|
|||||||
|
|
||||||
if created:
|
if created:
|
||||||
defaults.fallback_models = fallback_models
|
defaults.fallback_models = fallback_models
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def delete_model_configuration(
|
def delete_model_configuration(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
name = (_query_first(query, "name") or "").strip()
|
name = (_query_first(query, "name") or "").strip()
|
||||||
if not name or name == "default":
|
if not name or name == "default":
|
||||||
raise WebUISettingsError("model configuration is required")
|
raise WebUISettingsError("model configuration is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if name not in config.model_presets:
|
if name not in config.model_presets:
|
||||||
raise WebUISettingsError("unknown model configuration")
|
raise WebUISettingsError("unknown model configuration")
|
||||||
defaults = config.agents.defaults
|
defaults = config.agents.defaults
|
||||||
@@ -1725,15 +1680,11 @@ def delete_model_configuration(
|
|||||||
)
|
)
|
||||||
|
|
||||||
del config.model_presets[name]
|
del config.model_presets[name]
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def create_provider_settings(
|
def create_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
display_name = (_query_first_alias(query, "name", "displayName") or "").strip()
|
||||||
if not display_name:
|
if not display_name:
|
||||||
raise WebUISettingsError("provider name is required")
|
raise WebUISettingsError("provider name is required")
|
||||||
@@ -1758,7 +1709,7 @@ def create_provider_settings(
|
|||||||
if not api_base:
|
if not api_base:
|
||||||
raise WebUISettingsError("API base is required")
|
raise WebUISettingsError("API base is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
if _provider_display_name_exists(config, display_name):
|
if _provider_display_name_exists(config, display_name):
|
||||||
raise WebUISettingsError("provider already exists", status=409)
|
raise WebUISettingsError("provider already exists", status=409)
|
||||||
|
|
||||||
@@ -1767,22 +1718,18 @@ def create_provider_settings(
|
|||||||
updates["api_type"] = "auto"
|
updates["api_type"] = "auto"
|
||||||
provider_config = _validated_provider_config(None, updates)
|
provider_config = _validated_provider_config(None, updates)
|
||||||
setattr(config.providers, provider_key, provider_config)
|
setattr(config.providers, provider_key, provider_config)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
payload = settings_payload(config_path=config_path)
|
payload = settings_payload()
|
||||||
payload["created_provider"] = provider_key
|
payload["created_provider"] = provider_key
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def update_provider_settings(
|
def update_provider_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
resolved_provider = _resolve_settings_provider(config, provider_name)
|
resolved_provider = _resolve_settings_provider(config, provider_name)
|
||||||
if resolved_provider is None:
|
if resolved_provider is None:
|
||||||
raise WebUISettingsError("unknown provider")
|
raise WebUISettingsError("unknown provider")
|
||||||
@@ -1824,7 +1771,7 @@ def update_provider_settings(
|
|||||||
changed = updated_provider_config != provider_config
|
changed = updated_provider_config != provider_config
|
||||||
if changed:
|
if changed:
|
||||||
setattr(config.providers, provider_key, updated_provider_config)
|
setattr(config.providers, provider_key, updated_provider_config)
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
restart_required = (
|
restart_required = (
|
||||||
changed
|
changed
|
||||||
@@ -1832,15 +1779,10 @@ def update_provider_settings(
|
|||||||
and image_config.provider == provider_key
|
and image_config.provider == provider_key
|
||||||
and get_image_gen_provider(provider_key) is not None
|
and get_image_gen_provider(provider_key) is not None
|
||||||
)
|
)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def login_oauth_provider(
|
def login_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -1852,13 +1794,12 @@ def login_oauth_provider(
|
|||||||
try:
|
try:
|
||||||
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
|
from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(
|
||||||
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
|
) from None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(
|
proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None
|
||||||
_load_settings_config(config_path),
|
|
||||||
config_path=config_path,
|
|
||||||
).providers.openai_codex.proxy or None
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
remote_browser_value = _query_first(query, "remote_browser")
|
remote_browser_value = _query_first(query, "remote_browser")
|
||||||
@@ -1876,7 +1817,7 @@ def login_oauth_provider(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
oauth_flows.register(spec.name, flow_id, flow)
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1893,23 +1834,22 @@ def login_oauth_provider(
|
|||||||
login_github_copilot,
|
login_github_copilot,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(
|
||||||
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
|
) from None
|
||||||
|
|
||||||
token = get_github_copilot_login_status()
|
token = get_github_copilot_login_status()
|
||||||
if not token:
|
if not token:
|
||||||
token = login_github_copilot(print_fn=lambda _message: None)
|
token = login_github_copilot(print_fn=lambda _message: None)
|
||||||
if not (token and token.access):
|
if not (token and token.access):
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
if spec.name == "xai_grok":
|
if spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
from nanobot.providers.xai_oauth import start_xai_oauth_login
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proxy = resolve_config_env_vars(
|
proxy = resolve_config_env_vars(load_config()).providers.xai_grok.proxy or None
|
||||||
_load_settings_config(config_path),
|
|
||||||
config_path=config_path,
|
|
||||||
).providers.xai_grok.proxy or None
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise WebUISettingsError(str(e), status=400) from e
|
raise WebUISettingsError(str(e), status=400) from e
|
||||||
try:
|
try:
|
||||||
@@ -1920,7 +1860,7 @@ def login_oauth_provider(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e
|
||||||
flow_id = secrets.token_urlsafe(24)
|
flow_id = secrets.token_urlsafe(24)
|
||||||
oauth_flows.register(spec.name, flow_id, flow)
|
_register_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
return {
|
return {
|
||||||
"status": "authorization_required",
|
"status": "authorization_required",
|
||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
@@ -1936,9 +1876,6 @@ def login_oauth_provider(
|
|||||||
def complete_oauth_provider(
|
def complete_oauth_provider(
|
||||||
query: QueryParams,
|
query: QueryParams,
|
||||||
authorization_response: str | None = None,
|
authorization_response: str | None = None,
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
flow_id = (_query_first(query, "flow_id") or "").strip()
|
flow_id = (_query_first(query, "flow_id") or "").strip()
|
||||||
@@ -1948,7 +1885,7 @@ def complete_oauth_provider(
|
|||||||
if not flow_id:
|
if not flow_id:
|
||||||
raise WebUISettingsError("flow_id is required")
|
raise WebUISettingsError("flow_id is required")
|
||||||
|
|
||||||
flow = oauth_flows.get(spec.name, flow_id)
|
flow = _get_webui_oauth_flow(spec.name, flow_id)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410)
|
||||||
|
|
||||||
@@ -1970,7 +1907,7 @@ def complete_oauth_provider(
|
|||||||
except WebUISettingsError:
|
except WebUISettingsError:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
oauth_flows.remove(spec.name, flow_id, flow)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow)
|
||||||
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e
|
||||||
if token is None:
|
if token is None:
|
||||||
return {
|
return {
|
||||||
@@ -1978,18 +1915,13 @@ def complete_oauth_provider(
|
|||||||
"provider": spec.name,
|
"provider": spec.name,
|
||||||
"flow_id": flow_id,
|
"flow_id": flow_id,
|
||||||
}
|
}
|
||||||
oauth_flows.remove(spec.name, flow_id, flow, cancel=False)
|
_remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False)
|
||||||
if not token.access:
|
if not token.access:
|
||||||
raise WebUISettingsError("OAuth login failed", status=401)
|
raise WebUISettingsError("OAuth login failed", status=401)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def logout_oauth_provider(
|
def logout_oauth_provider(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip()
|
provider_name = (_query_first(query, "provider") or "").strip()
|
||||||
if not provider_name:
|
if not provider_name:
|
||||||
raise WebUISettingsError("provider is required")
|
raise WebUISettingsError("provider is required")
|
||||||
@@ -2002,35 +1934,93 @@ def logout_oauth_provider(
|
|||||||
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
||||||
from oauth_cli_kit.storage import FileTokenStorage
|
from oauth_cli_kit.storage import FileTokenStorage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(
|
||||||
oauth_flows.clear(spec.name)
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
|
) from None
|
||||||
|
_clear_webui_oauth_flows(spec.name)
|
||||||
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path()
|
||||||
elif spec.name == "github_copilot":
|
elif spec.name == "github_copilot":
|
||||||
try:
|
try:
|
||||||
from nanobot.providers.github_copilot_provider import get_storage
|
from nanobot.providers.github_copilot_provider import get_storage
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise WebUISettingsError(OAUTH_CLI_KIT_MISSING_MESSAGE, status=500) from None
|
raise WebUISettingsError(
|
||||||
|
"oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500
|
||||||
|
) from None
|
||||||
token_path = get_storage().get_token_path()
|
token_path = get_storage().get_token_path()
|
||||||
elif spec.name == "xai_grok":
|
elif spec.name == "xai_grok":
|
||||||
from nanobot.providers.xai_oauth import logout_xai_oauth
|
from nanobot.providers.xai_oauth import logout_xai_oauth
|
||||||
|
|
||||||
oauth_flows.clear(spec.name)
|
_clear_webui_oauth_flows(spec.name)
|
||||||
logout_xai_oauth()
|
logout_xai_oauth()
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
else:
|
else:
|
||||||
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
raise WebUISettingsError("OAuth logout is not supported for this provider")
|
||||||
|
|
||||||
for path in (token_path, token_path.with_suffix(".lock")):
|
for path in (token_path, token_path.with_suffix(".lock")):
|
||||||
with suppress(FileNotFoundError):
|
with suppress(FileNotFoundError):
|
||||||
path.unlink()
|
path.unlink()
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_network_safety_settings(
|
def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None:
|
||||||
query: QueryParams,
|
discarded: list[Any] = []
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()):
|
||||||
|
if existing.expired:
|
||||||
|
discarded.append(_webui_oauth_flows.pop(existing_id)[1])
|
||||||
|
while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS:
|
||||||
|
oldest_id = next(iter(_webui_oauth_flows))
|
||||||
|
discarded.append(_webui_oauth_flows.pop(oldest_id)[1])
|
||||||
|
_webui_oauth_flows[flow_id] = (provider_name, flow)
|
||||||
|
for existing in discarded:
|
||||||
|
existing.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
|
if registered is None or registered[0] != provider_name:
|
||||||
|
return None
|
||||||
|
flow = registered[1]
|
||||||
|
if not flow.expired:
|
||||||
|
return flow
|
||||||
|
_webui_oauth_flows.pop(flow_id, None)
|
||||||
|
flow.cancel()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_webui_oauth_flow(
|
||||||
|
provider_name: str,
|
||||||
|
flow_id: str,
|
||||||
|
flow: Any,
|
||||||
*,
|
*,
|
||||||
config_path: Path | None = None,
|
cancel: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
registered = _webui_oauth_flows.get(flow_id)
|
||||||
|
if (
|
||||||
|
registered is not None
|
||||||
|
and registered[0] == provider_name
|
||||||
|
and registered[1] is flow
|
||||||
|
):
|
||||||
|
_webui_oauth_flows.pop(flow_id)
|
||||||
|
if cancel:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_webui_oauth_flows(provider_name: str) -> None:
|
||||||
|
with _webui_oauth_flows_lock:
|
||||||
|
flow_ids = [
|
||||||
|
flow_id
|
||||||
|
for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items()
|
||||||
|
if registered_provider == provider_name
|
||||||
|
]
|
||||||
|
flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids]
|
||||||
|
for flow in flows:
|
||||||
|
flow.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
raw_allow = (
|
raw_allow = (
|
||||||
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
_query_first_alias(query, "webui_allow_local_service_access", "webuiAllowLocalServiceAccess")
|
||||||
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
or _query_first_alias(query, "allow_local_preview_access", "allowLocalPreviewAccess")
|
||||||
@@ -2039,7 +2029,7 @@ def update_network_safety_settings(
|
|||||||
if raw_allow is None and raw_default_access_mode is None:
|
if raw_allow is None and raw_default_access_mode is None:
|
||||||
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
raise WebUISettingsError("webui_allow_local_service_access or webui_default_access_mode is required")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
changed = False
|
changed = False
|
||||||
if raw_allow is not None:
|
if raw_allow is not None:
|
||||||
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
webui_allow_local_service_access = _parse_bool(raw_allow, "webui_allow_local_service_access")
|
||||||
@@ -2048,7 +2038,7 @@ def update_network_safety_settings(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
if raw_default_access_mode is not None:
|
if raw_default_access_mode is not None:
|
||||||
default_access_mode = raw_default_access_mode.strip().lower()
|
default_access_mode = raw_default_access_mode.strip().lower()
|
||||||
if default_access_mode == "restricted":
|
if default_access_mode == "restricted":
|
||||||
@@ -2059,20 +2049,16 @@ def update_network_safety_settings(
|
|||||||
write_webui_default_access_mode(default_access_mode)
|
write_webui_default_access_mode(default_access_mode)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise WebUISettingsError(str(exc)) from exc
|
raise WebUISettingsError(str(exc)) from exc
|
||||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
def update_web_search_settings(
|
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
provider_name = (_query_first(query, "provider") or "").strip().lower()
|
||||||
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
|
||||||
if provider_option is None:
|
if provider_option is None:
|
||||||
raise WebUISettingsError("unknown web search provider")
|
raise WebUISettingsError("unknown web search provider")
|
||||||
|
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
search_config = config.tools.web.search
|
search_config = config.tools.web.search
|
||||||
web_config = config.tools.web
|
web_config = config.tools.web
|
||||||
previous_provider = search_config.provider
|
previous_provider = search_config.provider
|
||||||
@@ -2151,17 +2137,13 @@ def update_web_search_settings(
|
|||||||
restart_required = True
|
restart_required = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=restart_required, config_path=config_path)
|
return settings_payload(requires_restart=restart_required)
|
||||||
|
|
||||||
|
|
||||||
def update_api_settings(
|
def update_api_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Update the managed OpenAI-compatible API configuration."""
|
"""Update the managed OpenAI-compatible API configuration."""
|
||||||
config = _load_settings_config(config_path)
|
config = load_config()
|
||||||
api = config.api
|
api = config.api
|
||||||
|
|
||||||
host = _query_first(query, "host")
|
host = _query_first(query, "host")
|
||||||
@@ -2198,16 +2180,12 @@ def update_api_settings(
|
|||||||
if not is_loopback_host(api.host) and not api.api_key.strip():
|
if not is_loopback_host(api.host) and not api.api_key.strip():
|
||||||
raise WebUISettingsError("an API key is required when the API is available on the network")
|
raise WebUISettingsError("an API key is required when the API is available on the network")
|
||||||
|
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|
||||||
|
|
||||||
def update_image_generation_settings(
|
def update_image_generation_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
image_config = config.tools.image_generation
|
image_config = config.tools.image_generation
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2300,16 +2278,12 @@ def update_image_generation_settings(
|
|||||||
raise WebUISettingsError("image generation provider is not configured")
|
raise WebUISettingsError("image generation provider is not configured")
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(requires_restart=changed, config_path=config_path)
|
return settings_payload(requires_restart=changed)
|
||||||
|
|
||||||
|
|
||||||
def update_transcription_settings(
|
def update_transcription_settings(query: QueryParams) -> dict[str, Any]:
|
||||||
query: QueryParams,
|
config = load_config()
|
||||||
*,
|
|
||||||
config_path: Path | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
config = _load_settings_config(config_path)
|
|
||||||
transcription = config.transcription
|
transcription = config.transcription
|
||||||
changed = False
|
changed = False
|
||||||
|
|
||||||
@@ -2374,5 +2348,5 @@ def update_transcription_settings(
|
|||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
_save_settings_config(config, config_path)
|
save_config(config)
|
||||||
return settings_payload(config_path=config_path)
|
return settings_payload()
|
||||||
|
|||||||
+155
-220
@@ -13,6 +13,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
@@ -30,7 +31,7 @@ from nanobot.channels.contracts import (
|
|||||||
)
|
)
|
||||||
from nanobot.channels.registry import load_channel_plugin
|
from nanobot.channels.registry import load_channel_plugin
|
||||||
from nanobot.channels.validation import validate_channel_config
|
from nanobot.channels.validation import validate_channel_config
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.loader import get_config_path, load_config, save_config
|
||||||
from nanobot.optional_features import (
|
from nanobot.optional_features import (
|
||||||
OptionalFeatureError,
|
OptionalFeatureError,
|
||||||
extra_installed,
|
extra_installed,
|
||||||
@@ -39,6 +40,7 @@ from nanobot.optional_features import (
|
|||||||
)
|
)
|
||||||
from nanobot.pairing import approve_code, deny_code, list_pending
|
from nanobot.pairing import approve_code, deny_code, list_pending
|
||||||
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
from nanobot.webui.cli_apps_api import cli_apps_action, cli_apps_payload
|
||||||
|
from nanobot.webui.http_utils import case_insensitive_header
|
||||||
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request as _is_local_browser_request
|
||||||
from nanobot.webui.http_utils import query_first as _query_first
|
from nanobot.webui.http_utils import query_first as _query_first
|
||||||
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
from nanobot.webui.mcp_presets_api import mcp_presets_settings_action
|
||||||
@@ -70,13 +72,21 @@ from nanobot.webui.settings_api import (
|
|||||||
update_transcription_settings,
|
update_transcription_settings,
|
||||||
update_web_search_settings,
|
update_web_search_settings,
|
||||||
)
|
)
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
from nanobot.webui.version_check import check_for_update
|
from nanobot.webui.version_check import check_for_update
|
||||||
|
|
||||||
QueryParams = dict[str, list[str]]
|
QueryParams = dict[str, list[str]]
|
||||||
|
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_MCP_VALUES_HEADER = "X-Nanobot-MCP-Values"
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
_MCP_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values"
|
||||||
|
_PROVIDER_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values"
|
||||||
|
_CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024
|
||||||
|
_API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values"
|
||||||
|
_API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024
|
||||||
|
_OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code"
|
||||||
|
_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback"
|
||||||
|
_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024
|
||||||
|
|
||||||
_SKIP_FIELD = object()
|
_SKIP_FIELD = object()
|
||||||
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
_CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"})
|
||||||
@@ -102,63 +112,6 @@ _MCP_PRESET_ACTIONS_BY_PATH = {
|
|||||||
"/api/settings/mcp-presets/tools": "tools",
|
"/api/settings/mcp-presets/tools": "tools",
|
||||||
}
|
}
|
||||||
|
|
||||||
_SETTINGS_MUTATION_PATHS = frozenset({
|
|
||||||
"/api/settings/update",
|
|
||||||
"/api/settings/model-configurations/create",
|
|
||||||
"/api/settings/model-configurations/update",
|
|
||||||
"/api/settings/model-configurations/delete",
|
|
||||||
"/api/settings/model-configurations/migrate",
|
|
||||||
"/api/settings/model-call-order/update",
|
|
||||||
"/api/settings/provider/update",
|
|
||||||
"/api/settings/provider/create",
|
|
||||||
"/api/settings/provider/oauth-login",
|
|
||||||
"/api/settings/provider/oauth-login/complete",
|
|
||||||
"/api/settings/provider/oauth-logout",
|
|
||||||
"/api/settings/web-search/update",
|
|
||||||
"/api/settings/api-service/start",
|
|
||||||
"/api/settings/api-service/stop",
|
|
||||||
"/api/settings/image-generation/update",
|
|
||||||
"/api/settings/transcription/update",
|
|
||||||
"/api/settings/network-safety/update",
|
|
||||||
"/api/settings/cli-apps/install",
|
|
||||||
"/api/settings/cli-apps/update",
|
|
||||||
"/api/settings/cli-apps/uninstall",
|
|
||||||
"/api/settings/cli-apps/test",
|
|
||||||
"/api/settings/nanobot-features/enable",
|
|
||||||
"/api/settings/nanobot-features/disable",
|
|
||||||
"/api/settings/channels/validate",
|
|
||||||
"/api/settings/channels/configure",
|
|
||||||
"/api/settings/pairing/approve",
|
|
||||||
"/api/settings/pairing/deny",
|
|
||||||
*_MCP_PRESET_ACTIONS_BY_PATH,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
|
||||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
return cast(dict[str, Any], payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _query_value(value: Any) -> str:
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, (dict, list)):
|
|
||||||
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _payload_query(payload: dict[str, Any]) -> QueryParams:
|
|
||||||
return {
|
|
||||||
key: [_query_value(value)]
|
|
||||||
for key, value in payload.items()
|
|
||||||
if key
|
|
||||||
and key not in {"authorization_response", "channel", "values"}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsRouter:
|
class WebUISettingsRouter:
|
||||||
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
"""Route WebUI Settings HTTP requests behind a transport-neutral boundary."""
|
||||||
@@ -166,7 +119,6 @@ class WebUISettingsRouter:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
settings: WebUISettingsServices,
|
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
logger: Any,
|
logger: Any,
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
check_api_token: Callable[[WsRequest], bool],
|
||||||
@@ -178,7 +130,6 @@ class WebUISettingsRouter:
|
|||||||
channel_feature_action: Callable[..., Any] | None = None,
|
channel_feature_action: Callable[..., Any] | None = None,
|
||||||
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.settings = settings
|
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._check_api_token = check_api_token
|
self._check_api_token = check_api_token
|
||||||
@@ -193,15 +144,6 @@ class WebUISettingsRouter:
|
|||||||
self._channel_connectors: dict[str, Any] = {}
|
self._channel_connectors: dict[str, Any] = {}
|
||||||
|
|
||||||
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
async def dispatch(self, connection: Any, request: WsRequest, path: str) -> Response | None:
|
||||||
if self.is_mutation_path(path) and not getattr(
|
|
||||||
request,
|
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR,
|
|
||||||
False,
|
|
||||||
):
|
|
||||||
return self._error_response(
|
|
||||||
405,
|
|
||||||
"WebUI mutations require an authenticated WebSocket",
|
|
||||||
)
|
|
||||||
if path == "/api/settings":
|
if path == "/api/settings":
|
||||||
return self._handle_settings(request)
|
return self._handle_settings(request)
|
||||||
if path == "/api/settings/usage":
|
if path == "/api/settings/usage":
|
||||||
@@ -288,17 +230,7 @@ class WebUISettingsRouter:
|
|||||||
return await self._handle_settings_mcp_presets(request, mcp_action)
|
return await self._handle_settings_mcp_presets(request, mcp_action)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def is_mutation_path(path: str) -> bool:
|
|
||||||
return (
|
|
||||||
path in _SETTINGS_MUTATION_PATHS
|
|
||||||
or _channel_connect_route(path) is not None
|
|
||||||
)
|
|
||||||
|
|
||||||
def _query(self, request: WsRequest) -> QueryParams:
|
def _query(self, request: WsRequest) -> QueryParams:
|
||||||
payload = _mutation_payload(request)
|
|
||||||
if payload is not None:
|
|
||||||
return _payload_query(payload)
|
|
||||||
return self._parse_query(request.path)
|
return self._parse_query(request.path)
|
||||||
|
|
||||||
def _authorized(self, request: WsRequest) -> bool:
|
def _authorized(self, request: WsRequest) -> bool:
|
||||||
@@ -328,18 +260,70 @@ class WebUISettingsRouter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_mcp_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
query = self._query(request)
|
||||||
|
raw = request.headers.get(_MCP_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _MCP_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("MCP settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid MCP settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("MCP settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[object, Any], payload)
|
||||||
|
merged = {key: list(values) for key, values in query.items()}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise WebUISettingsError("MCP settings payload contains an invalid key")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value.strip()
|
||||||
|
else:
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
if text:
|
||||||
|
merged[key] = [text]
|
||||||
|
return merged
|
||||||
|
|
||||||
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_provider_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
return self._query(request)
|
query = self._query(request)
|
||||||
|
raw = request.headers.get(_PROVIDER_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _PROVIDER_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("provider settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
try:
|
||||||
|
payload = json.loads(unquote(raw))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
raise WebUISettingsError("invalid provider settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("provider settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[object, Any], payload)
|
||||||
|
|
||||||
|
merged = {key: list(values) for key, values in query.items()}
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str) or not key:
|
||||||
|
raise WebUISettingsError("provider settings payload contains an invalid key")
|
||||||
|
if isinstance(value, str):
|
||||||
|
text = value
|
||||||
|
elif value is None:
|
||||||
|
text = ""
|
||||||
|
else:
|
||||||
|
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
merged[key] = [text]
|
||||||
|
return merged
|
||||||
|
|
||||||
def _handle_settings(self, request: WsRequest) -> Response:
|
def _handle_settings(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(
|
return self._json_response(
|
||||||
self._with_restart_state(
|
self._with_restart_state(
|
||||||
self.settings.read(
|
settings_payload(
|
||||||
settings_payload,
|
|
||||||
surface=self._runtime_surface,
|
surface=self._runtime_surface,
|
||||||
runtime_capability_overrides=self._runtime_capabilities,
|
runtime_capability_overrides=self._runtime_capabilities,
|
||||||
)
|
)
|
||||||
@@ -349,7 +333,7 @@ class WebUISettingsRouter:
|
|||||||
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
def _handle_settings_usage(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
return self._json_response(self.settings.read(settings_usage_payload))
|
return self._json_response(settings_usage_payload())
|
||||||
|
|
||||||
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
def _handle_settings_pairing(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -395,7 +379,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(update_agent_settings, self._query(request))
|
payload = update_agent_settings(self._query(request))
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -404,10 +388,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = create_model_configuration(self._query(request))
|
||||||
create_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -416,10 +397,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_model_configuration(self._query(request))
|
||||||
update_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -428,10 +406,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = delete_model_configuration(self._query(request))
|
||||||
delete_model_configuration,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -440,10 +415,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = migrate_model_configurations(self._query(request))
|
||||||
migrate_model_configurations,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -452,10 +424,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_model_call_order(self._query(request))
|
||||||
update_model_call_order,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -464,10 +433,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_provider_settings(self._parse_provider_settings_query(request))
|
||||||
update_provider_settings,
|
|
||||||
self._parse_provider_settings_query(request)
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -477,10 +443,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = create_provider_settings(self._parse_provider_settings_query(request))
|
||||||
create_provider_settings,
|
|
||||||
self._parse_provider_settings_query(request)
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -489,11 +452,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(provider_models_payload, self._query(request))
|
||||||
self.settings.read,
|
|
||||||
provider_models_payload,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -511,33 +470,27 @@ class WebUISettingsRouter:
|
|||||||
query = self._query(request)
|
query = self._query(request)
|
||||||
try:
|
try:
|
||||||
if action == "login":
|
if action == "login":
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(login_oauth_provider, query)
|
||||||
self.settings.read,
|
|
||||||
login_oauth_provider,
|
|
||||||
query,
|
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
|
||||||
elif action == "complete":
|
elif action == "complete":
|
||||||
raw_response = (_mutation_payload(request) or {}).get(
|
authorization_response = case_insensitive_header(
|
||||||
"authorization_response"
|
request.headers,
|
||||||
|
_OAUTH_CALLBACK_HEADER,
|
||||||
|
) or case_insensitive_header(
|
||||||
|
request.headers,
|
||||||
|
_OAUTH_CODE_HEADER,
|
||||||
)
|
)
|
||||||
if raw_response is not None and not isinstance(raw_response, str):
|
if (
|
||||||
raise WebUISettingsError("OAuth authorization response must be a string")
|
len(authorization_response.encode("utf-8"))
|
||||||
authorization_response = raw_response
|
> _OAUTH_RESPONSE_HEADER_MAX_BYTES
|
||||||
|
):
|
||||||
|
raise WebUISettingsError("OAuth authorization response is too large")
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
self.settings.read,
|
|
||||||
complete_oauth_provider,
|
complete_oauth_provider,
|
||||||
query,
|
query,
|
||||||
authorization_response or None,
|
authorization_response or None,
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(logout_oauth_provider, query)
|
||||||
self.settings.read,
|
|
||||||
logout_oauth_provider,
|
|
||||||
query,
|
|
||||||
oauth_flows=self.settings.oauth_flows,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
if payload.get("status") in {"authorization_required", "pending"}:
|
if payload.get("status") in {"authorization_required", "pending"}:
|
||||||
@@ -548,10 +501,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_web_search_settings(self._query(request))
|
||||||
update_web_search_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="browser"))
|
return self._json_response(self._with_restart_state(payload, section="browser"))
|
||||||
@@ -570,22 +520,19 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
{"name": ["api"]},
|
{"name": ["api"]},
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
self.settings.mutate(
|
update_api_settings(self._parse_api_service_settings_query(request))
|
||||||
update_api_settings,
|
config = load_config()
|
||||||
self._parse_api_service_settings_query(request),
|
|
||||||
)
|
|
||||||
config = self.settings.config.load()
|
|
||||||
runtime = self._api_runtime()
|
runtime = self._api_runtime()
|
||||||
options = ApiStartOptions(
|
options = ApiStartOptions(
|
||||||
host=config.api.host,
|
host=config.api.host,
|
||||||
port=config.api.port,
|
port=config.api.port,
|
||||||
workspace=str(config.workspace_path),
|
workspace=str(config.workspace_path),
|
||||||
config_path=str(self.settings.config.path),
|
config_path=str(get_config_path().expanduser().resolve(strict=False)),
|
||||||
)
|
)
|
||||||
current = runtime.status()
|
current = runtime.status()
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
@@ -602,12 +549,33 @@ class WebUISettingsRouter:
|
|||||||
return self._json_response(self._api_service_payload(last_action="started"))
|
return self._json_response(self._api_service_payload(last_action="started"))
|
||||||
|
|
||||||
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
def _parse_api_service_settings_query(self, request: WsRequest) -> QueryParams:
|
||||||
payload = _mutation_payload(request)
|
query = self._query(request)
|
||||||
if payload is not None:
|
if "api_key" in query or "apiKey" in query:
|
||||||
|
raise WebUISettingsError("API service API key must be provided in the private header")
|
||||||
|
raw = request.headers.get(_API_SERVICE_VALUES_HEADER)
|
||||||
|
if not raw:
|
||||||
|
return query
|
||||||
|
if len(raw.encode("utf-8")) > _API_SERVICE_VALUES_HEADER_MAX_BYTES:
|
||||||
|
raise WebUISettingsError("API service settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid API service settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WebUISettingsError("API service settings payload must be a JSON object")
|
||||||
|
payload = cast(dict[str, Any], payload)
|
||||||
|
|
||||||
|
unknown = set(payload) - {"api_key"}
|
||||||
|
if unknown:
|
||||||
|
raise WebUISettingsError("API service settings payload contains an invalid key")
|
||||||
api_key = payload.get("api_key")
|
api_key = payload.get("api_key")
|
||||||
if api_key is not None and not isinstance(api_key, str):
|
if api_key is not None and not isinstance(api_key, str):
|
||||||
raise WebUISettingsError("API service API key must be a string")
|
raise WebUISettingsError("API service API key must be a string")
|
||||||
return self._query(request)
|
|
||||||
|
merged = {key: list(values) for key, values in query.items() if key != "api_key"}
|
||||||
|
if api_key is not None:
|
||||||
|
merged["api_key"] = [api_key]
|
||||||
|
return merged
|
||||||
|
|
||||||
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
async def _handle_settings_api_service_stop(self, request: WsRequest) -> Response:
|
||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
@@ -621,11 +589,13 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, self._api_runtime_message(result.message))
|
return self._error_response(500, self._api_runtime_message(result.message))
|
||||||
return self._json_response(self._api_service_payload(last_action="stopped"))
|
return self._json_response(self._api_service_payload(last_action="stopped"))
|
||||||
|
|
||||||
def _api_runtime(self) -> ApiRuntime:
|
@staticmethod
|
||||||
return ApiRuntime(paths=api_runtime_paths(self.settings.config.path))
|
def _api_runtime() -> ApiRuntime:
|
||||||
|
config_path = get_config_path().expanduser().resolve(strict=False)
|
||||||
|
return ApiRuntime(paths=api_runtime_paths(config_path))
|
||||||
|
|
||||||
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
def _api_service_payload(self, *, last_action: str | None = None) -> dict[str, Any]:
|
||||||
config = self.settings.config.load()
|
config = load_config()
|
||||||
status = self._api_runtime().status()
|
status = self._api_runtime().status()
|
||||||
extras = optional_dependency_groups()
|
extras = optional_dependency_groups()
|
||||||
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
connect_host = "127.0.0.1" if config.api.host in {"0.0.0.0", "::"} else config.api.host
|
||||||
@@ -669,10 +639,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_image_generation_settings(self._query(request))
|
||||||
update_image_generation_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
payload = await self._apply_image_generation_runtime_change(payload)
|
payload = await self._apply_image_generation_runtime_change(payload)
|
||||||
@@ -707,10 +674,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_transcription_settings(self._query(request))
|
||||||
update_transcription_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload))
|
return self._json_response(self._with_restart_state(payload))
|
||||||
@@ -719,10 +683,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = self.settings.mutate(
|
payload = update_network_safety_settings(self._query(request))
|
||||||
update_network_safety_settings,
|
|
||||||
self._query(request),
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
return self._json_response(self._with_restart_state(payload, section="runtime"))
|
||||||
@@ -736,10 +697,7 @@ class WebUISettingsRouter:
|
|||||||
"yes",
|
"yes",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
payload = await cli_apps_payload(
|
payload = await cli_apps_payload(installed_only=installed_only)
|
||||||
installed_only=installed_only,
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load CLI Apps payload")
|
self.logger.exception("failed to load CLI Apps payload")
|
||||||
return self._error_response(500, "failed to load CLI Apps")
|
return self._error_response(500, "failed to load CLI Apps")
|
||||||
@@ -753,12 +711,7 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(cli_apps_action, action, self._query(request))
|
||||||
cli_apps_action,
|
|
||||||
action,
|
|
||||||
self._query(request),
|
|
||||||
config_path=self.settings.config.path,
|
|
||||||
)
|
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
return self._error_response(e.status, e.message)
|
return self._error_response(e.status, e.message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -773,29 +726,12 @@ class WebUISettingsRouter:
|
|||||||
if not self._authorized(request):
|
if not self._authorized(request):
|
||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(self._nanobot_features_payload)
|
payload = await asyncio.to_thread(nanobot_features_payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load nanobot features")
|
self.logger.exception("failed to load nanobot features")
|
||||||
return self._error_response(500, "failed to load nanobot features")
|
return self._error_response(500, "failed to load nanobot features")
|
||||||
return self._json_response(self._with_channel_runtime_status(payload))
|
return self._json_response(self._with_channel_runtime_status(payload))
|
||||||
|
|
||||||
def _nanobot_features_payload(self) -> dict[str, Any]:
|
|
||||||
return nanobot_features_payload(config_path=self.settings.config.path)
|
|
||||||
|
|
||||||
def _nanobot_features_action(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
query: QueryParams,
|
|
||||||
*,
|
|
||||||
allow_install: bool = True,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return self.settings.mutate(
|
|
||||||
nanobot_features_action,
|
|
||||||
action,
|
|
||||||
query,
|
|
||||||
allow_install=allow_install,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_settings_nanobot_features_action(
|
async def _handle_settings_nanobot_features_action(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -806,7 +742,7 @@ class WebUISettingsRouter:
|
|||||||
return self._unauthorized()
|
return self._unauthorized()
|
||||||
try:
|
try:
|
||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
action,
|
action,
|
||||||
self._query(request),
|
self._query(request),
|
||||||
allow_install=action != "enable"
|
allow_install=action != "enable"
|
||||||
@@ -914,7 +850,7 @@ class WebUISettingsRouter:
|
|||||||
saved = await asyncio.to_thread(
|
saved = await asyncio.to_thread(
|
||||||
self._save_channel_config_values,
|
self._save_channel_config_values,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values(request),
|
self._parse_channel_values_header(request),
|
||||||
instance_id,
|
instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -929,7 +865,7 @@ class WebUISettingsRouter:
|
|||||||
"saved_keys": saved,
|
"saved_keys": saved,
|
||||||
}
|
}
|
||||||
if not enable:
|
if not enable:
|
||||||
features = await asyncio.to_thread(self._nanobot_features_payload)
|
features = await asyncio.to_thread(nanobot_features_payload)
|
||||||
features = self._with_channel_runtime_status(features)
|
features = self._with_channel_runtime_status(features)
|
||||||
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
payload["nanobot_features"] = self._with_restart_state(features, section="runtime")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
@@ -940,7 +876,7 @@ class WebUISettingsRouter:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
feature_query,
|
feature_query,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
@@ -970,7 +906,7 @@ class WebUISettingsRouter:
|
|||||||
payload = await asyncio.to_thread(
|
payload = await asyncio.to_thread(
|
||||||
validate_channel_config,
|
validate_channel_config,
|
||||||
name,
|
name,
|
||||||
self._parse_channel_values(request),
|
self._parse_channel_values_header(request),
|
||||||
instance_id=instance_id,
|
instance_id=instance_id,
|
||||||
)
|
)
|
||||||
except WebUISettingsError as e:
|
except WebUISettingsError as e:
|
||||||
@@ -980,14 +916,19 @@ class WebUISettingsRouter:
|
|||||||
return self._error_response(500, "failed to validate channel settings")
|
return self._error_response(500, "failed to validate channel settings")
|
||||||
return self._json_response(payload)
|
return self._json_response(payload)
|
||||||
|
|
||||||
def _parse_channel_values(self, request: WsRequest) -> dict[str, Any]:
|
def _parse_channel_values_header(self, request: WsRequest) -> dict[str, Any]:
|
||||||
payload = _mutation_payload(request)
|
raw = request.headers.get(_CHANNEL_VALUES_HEADER)
|
||||||
if payload is None or "values" not in payload:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
values = payload.get("values")
|
if len(raw.encode("utf-8")) > _CHANNEL_VALUES_HEADER_MAX_BYTES:
|
||||||
if not isinstance(values, dict):
|
raise WebUISettingsError("channel settings payload is too large")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise WebUISettingsError("invalid channel settings payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
raise WebUISettingsError("channel settings payload must be a JSON object")
|
raise WebUISettingsError("channel settings payload must be a JSON object")
|
||||||
return cast(dict[str, Any], values)
|
return cast(dict[str, Any], payload)
|
||||||
|
|
||||||
def _save_channel_config_values(
|
def _save_channel_config_values(
|
||||||
self,
|
self,
|
||||||
@@ -1008,7 +949,7 @@ class WebUISettingsRouter:
|
|||||||
if not raw_values:
|
if not raw_values:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def update(config: Config) -> list[str]:
|
config = load_config()
|
||||||
section = getattr(config.channels, name, None)
|
section = getattr(config.channels, name, None)
|
||||||
channel_config = channel_instance_config(
|
channel_config = channel_instance_config(
|
||||||
plugin,
|
plugin,
|
||||||
@@ -1020,9 +961,7 @@ class WebUISettingsRouter:
|
|||||||
prefix = f"channels.{name}."
|
prefix = f"channels.{name}."
|
||||||
for raw_key, raw_value in raw_values.items():
|
for raw_key, raw_value in raw_values.items():
|
||||||
if not raw_key:
|
if not raw_key:
|
||||||
raise WebUISettingsError(
|
raise WebUISettingsError("channel settings payload contains an invalid key")
|
||||||
"channel settings payload contains an invalid key"
|
|
||||||
)
|
|
||||||
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
field = raw_key[len(prefix):] if raw_key.startswith(prefix) else raw_key
|
||||||
value_type = field_types.get(field)
|
value_type = field_types.get(field)
|
||||||
if value_type is None:
|
if value_type is None:
|
||||||
@@ -1046,10 +985,9 @@ class WebUISettingsRouter:
|
|||||||
status=400,
|
status=400,
|
||||||
) from exc
|
) from exc
|
||||||
setattr(config.channels, name, updated_section)
|
setattr(config.channels, name, updated_section)
|
||||||
|
save_config(config)
|
||||||
return saved
|
return saved
|
||||||
|
|
||||||
return self.settings.config.update(update)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_channel_value(
|
def _coerce_channel_value(
|
||||||
raw_key: str,
|
raw_key: str,
|
||||||
@@ -1171,14 +1109,14 @@ class WebUISettingsRouter:
|
|||||||
target["instance_id"] = [str(payload["instance_id"])]
|
target["instance_id"] = [str(payload["instance_id"])]
|
||||||
try:
|
try:
|
||||||
features = await asyncio.to_thread(
|
features = await asyncio.to_thread(
|
||||||
self._nanobot_features_action,
|
nanobot_features_action,
|
||||||
"enable",
|
"enable",
|
||||||
target,
|
target,
|
||||||
allow_install=self._allow_feature_package_install(connection, request),
|
allow_install=self._allow_feature_package_install(connection, request),
|
||||||
)
|
)
|
||||||
except OptionalFeatureError as exc:
|
except OptionalFeatureError as exc:
|
||||||
features = self._feature_runtime_fallback(
|
features = self._feature_runtime_fallback(
|
||||||
self._nanobot_features_payload(),
|
nanobot_features_payload(),
|
||||||
message=(
|
message=(
|
||||||
f"{channel_name} connected, but enabling channel support failed: "
|
f"{channel_name} connected, but enabling channel support failed: "
|
||||||
f"{exc.message}"
|
f"{exc.message}"
|
||||||
@@ -1199,9 +1137,7 @@ class WebUISettingsRouter:
|
|||||||
if _is_local_browser_request(connection, request.headers):
|
if _is_local_browser_request(connection, request.headers):
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
return bool(
|
return bool(load_config().tools.webui_allow_remote_package_install)
|
||||||
self.settings.config.load().tools.webui_allow_remote_package_install
|
|
||||||
)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
self.logger.exception("failed to load remote package install policy")
|
self.logger.exception("failed to load remote package install policy")
|
||||||
return False
|
return False
|
||||||
@@ -1218,7 +1154,6 @@ class WebUISettingsRouter:
|
|||||||
action,
|
action,
|
||||||
self._parse_mcp_settings_query(request),
|
self._parse_mcp_settings_query(request),
|
||||||
reload_mcp=lambda: request_mcp_reload(self.bus),
|
reload_mcp=lambda: request_mcp_reload(self.bus),
|
||||||
config=self.settings.config,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
status = getattr(e, "status", 500)
|
status = getattr(e, "status", 500)
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
"""Gateway-owned state for the WebUI settings surface."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import threading
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, TypeVar
|
|
||||||
|
|
||||||
from nanobot.config.loader import load_config, save_config
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
|
||||||
_WEBUI_OAUTH_MAX_FLOWS = 8
|
|
||||||
|
|
||||||
|
|
||||||
class WebUISettingsConfig:
|
|
||||||
"""Instance-scoped config access with serialized read-modify-write operations."""
|
|
||||||
|
|
||||||
def __init__(self, config_path: Path) -> None:
|
|
||||||
self.path = config_path.expanduser().resolve(strict=False)
|
|
||||||
self._lock = threading.RLock()
|
|
||||||
|
|
||||||
def load(self) -> Config:
|
|
||||||
"""Load this gateway's config without consulting the process-global path."""
|
|
||||||
with self._lock:
|
|
||||||
return load_config(self.path)
|
|
||||||
|
|
||||||
def update(self, mutation: Callable[[Config], _T]) -> _T:
|
|
||||||
"""Apply and atomically persist one in-process read-modify-write operation."""
|
|
||||||
with self._lock:
|
|
||||||
config = load_config(self.path)
|
|
||||||
result = mutation(config)
|
|
||||||
save_config(config, self.path)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def run_serialized(self, operation: Callable[[Path], _T]) -> _T:
|
|
||||||
"""Run a path-aware read-modify-write operation under the instance lock."""
|
|
||||||
with self._lock:
|
|
||||||
return operation(self.path)
|
|
||||||
|
|
||||||
|
|
||||||
class WebUIOAuthFlowRegistry:
|
|
||||||
"""Bounded, thread-safe OAuth flows owned by one gateway instance."""
|
|
||||||
|
|
||||||
def __init__(self, *, max_flows: int = _WEBUI_OAUTH_MAX_FLOWS) -> None:
|
|
||||||
if max_flows < 1:
|
|
||||||
raise ValueError("max_flows must be at least one")
|
|
||||||
self._max_flows = max_flows
|
|
||||||
self._flows: dict[str, tuple[str, Any]] = {}
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
|
|
||||||
def register(self, provider_name: str, flow_id: str, flow: Any) -> None:
|
|
||||||
discarded: list[Any] = []
|
|
||||||
with self._lock:
|
|
||||||
for existing_id, (_provider_name, existing) in list(self._flows.items()):
|
|
||||||
if existing.expired:
|
|
||||||
discarded.append(self._flows.pop(existing_id)[1])
|
|
||||||
while len(self._flows) >= self._max_flows:
|
|
||||||
oldest_id = next(iter(self._flows))
|
|
||||||
discarded.append(self._flows.pop(oldest_id)[1])
|
|
||||||
self._flows[flow_id] = (provider_name, flow)
|
|
||||||
for existing in discarded:
|
|
||||||
existing.cancel()
|
|
||||||
|
|
||||||
def get(self, provider_name: str, flow_id: str) -> Any | None:
|
|
||||||
with self._lock:
|
|
||||||
registered = self._flows.get(flow_id)
|
|
||||||
if registered is None or registered[0] != provider_name:
|
|
||||||
return None
|
|
||||||
flow = registered[1]
|
|
||||||
if not flow.expired:
|
|
||||||
return flow
|
|
||||||
self._flows.pop(flow_id, None)
|
|
||||||
flow.cancel()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def remove(
|
|
||||||
self,
|
|
||||||
provider_name: str,
|
|
||||||
flow_id: str,
|
|
||||||
flow: Any,
|
|
||||||
*,
|
|
||||||
cancel: bool = True,
|
|
||||||
) -> None:
|
|
||||||
with self._lock:
|
|
||||||
registered = self._flows.get(flow_id)
|
|
||||||
if (
|
|
||||||
registered is not None
|
|
||||||
and registered[0] == provider_name
|
|
||||||
and registered[1] is flow
|
|
||||||
):
|
|
||||||
self._flows.pop(flow_id)
|
|
||||||
if cancel:
|
|
||||||
flow.cancel()
|
|
||||||
|
|
||||||
def clear(self, provider_name: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
flow_ids = [
|
|
||||||
flow_id
|
|
||||||
for flow_id, (registered_provider, _flow) in self._flows.items()
|
|
||||||
if registered_provider == provider_name
|
|
||||||
]
|
|
||||||
flows = [self._flows.pop(flow_id)[1] for flow_id in flow_ids]
|
|
||||||
for flow in flows:
|
|
||||||
flow.cancel()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class WebUISettingsServices:
|
|
||||||
"""Settings dependencies composed once for a gateway instance."""
|
|
||||||
|
|
||||||
config: WebUISettingsConfig
|
|
||||||
oauth_flows: WebUIOAuthFlowRegistry
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def create(cls, config_path: Path) -> WebUISettingsServices:
|
|
||||||
return cls(
|
|
||||||
config=WebUISettingsConfig(config_path),
|
|
||||||
oauth_flows=WebUIOAuthFlowRegistry(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def read(
|
|
||||||
self,
|
|
||||||
operation: Callable[..., _T],
|
|
||||||
/,
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> _T:
|
|
||||||
"""Run a settings read against this gateway's explicit config path."""
|
|
||||||
return operation(*args, config_path=self.config.path, **kwargs)
|
|
||||||
|
|
||||||
def mutate(
|
|
||||||
self,
|
|
||||||
operation: Callable[..., _T],
|
|
||||||
/,
|
|
||||||
*args: Any,
|
|
||||||
**kwargs: Any,
|
|
||||||
) -> _T:
|
|
||||||
"""Serialize a path-aware settings read-modify-write operation."""
|
|
||||||
return self.config.run_serialized(
|
|
||||||
lambda config_path: operation(
|
|
||||||
*args,
|
|
||||||
config_path=config_path,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -25,7 +25,7 @@ _MAX_KEY_LEN = 512
|
|||||||
_MAX_TITLE_LEN = 160
|
_MAX_TITLE_LEN = 160
|
||||||
_MAX_TAG_LEN = 40
|
_MAX_TAG_LEN = 40
|
||||||
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
_ALLOWED_DENSITIES = {"comfortable", "compact"}
|
||||||
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc", "manual"}
|
_ALLOWED_SORTS = {"updated_desc", "created_desc", "title_asc"}
|
||||||
|
|
||||||
|
|
||||||
def webui_sidebar_state_path() -> Path:
|
def webui_sidebar_state_path() -> Path:
|
||||||
@@ -37,7 +37,6 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
"schema_version": WEBUI_SIDEBAR_STATE_SCHEMA_VERSION,
|
||||||
"pinned_keys": [],
|
"pinned_keys": [],
|
||||||
"archived_keys": [],
|
"archived_keys": [],
|
||||||
"session_order": [],
|
|
||||||
"title_overrides": {},
|
"title_overrides": {},
|
||||||
"project_name_overrides": {},
|
"project_name_overrides": {},
|
||||||
"tags_by_key": {},
|
"tags_by_key": {},
|
||||||
@@ -139,7 +138,6 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
|||||||
state = default_webui_sidebar_state()
|
state = default_webui_sidebar_state()
|
||||||
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
|
state["pinned_keys"] = _clean_string_list(raw.get("pinned_keys"))
|
||||||
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
|
state["archived_keys"] = _clean_string_list(raw.get("archived_keys"))
|
||||||
state["session_order"] = _clean_string_list(raw.get("session_order"))
|
|
||||||
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
|
state["title_overrides"] = _clean_title_overrides(raw.get("title_overrides"))
|
||||||
state["project_name_overrides"] = _clean_title_overrides(
|
state["project_name_overrides"] = _clean_title_overrides(
|
||||||
raw.get("project_name_overrides")
|
raw.get("project_name_overrides")
|
||||||
|
|||||||
@@ -1,218 +0,0 @@
|
|||||||
"""Connection-owned Temporary Chat behavior for the WebUI."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.bus.events import (
|
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
InboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
|
||||||
from nanobot.session.manager import Session, SessionManager
|
|
||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
|
||||||
|
|
||||||
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
|
|
||||||
"create_goal",
|
|
||||||
"update_goal",
|
|
||||||
"spawn",
|
|
||||||
"cron",
|
|
||||||
})
|
|
||||||
_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"})
|
|
||||||
|
|
||||||
|
|
||||||
class TemporaryChatError(ValueError):
|
|
||||||
"""A stable WebUI protocol error for a Temporary Chat operation."""
|
|
||||||
|
|
||||||
def __init__(self, detail: str) -> None:
|
|
||||||
super().__init__(detail)
|
|
||||||
self.detail = detail
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TemporaryChatMessagePolicy:
|
|
||||||
"""Server-owned message rules for one active Temporary Chat."""
|
|
||||||
|
|
||||||
session_key: str
|
|
||||||
workspace_scope: WorkspaceScope
|
|
||||||
require_existing_session: bool = True
|
|
||||||
hydrate_transcript: bool = False
|
|
||||||
persist_transcript: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class WebUITemporaryChats:
|
|
||||||
"""Own Temporary Chat creation, policy, attachments, and disposal."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
bus: MessageBus,
|
|
||||||
session_manager: SessionManager | None,
|
|
||||||
workspaces: WebUIWorkspaceController,
|
|
||||||
logger: Any,
|
|
||||||
channel_name: str = "websocket",
|
|
||||||
) -> None:
|
|
||||||
self._bus = bus
|
|
||||||
self._sessions = session_manager
|
|
||||||
self._workspaces = workspaces
|
|
||||||
self._logger = logger
|
|
||||||
self._channel_name = channel_name
|
|
||||||
self._owners: dict[str, object] = {}
|
|
||||||
self._owner_chat_ids: dict[object, set[str]] = {}
|
|
||||||
# Keep active sessions alive if the bounded manager cache evicts them
|
|
||||||
# between WebUI turns. SessionPolicy remains the authority below.
|
|
||||||
self._active_sessions: dict[str, Session] = {}
|
|
||||||
# Retain policy-derived tombstones until shutdown so late outbound
|
|
||||||
# events cannot create a durable transcript after a chat is discarded.
|
|
||||||
self._known_transient_chat_ids: set[str] = set()
|
|
||||||
self._media_paths: dict[str, set[str]] = {}
|
|
||||||
|
|
||||||
def _session_key(self, chat_id: str) -> str:
|
|
||||||
return f"{self._channel_name}:{chat_id}"
|
|
||||||
|
|
||||||
def _cached_session_is_transient(self, chat_id: str) -> bool:
|
|
||||||
if self._sessions is None:
|
|
||||||
return False
|
|
||||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
|
||||||
return session is not None and not session.policy.persist
|
|
||||||
|
|
||||||
def create(self, owner: object, *, trusted_webui: bool) -> str:
|
|
||||||
"""Create a server-identified chat owned by one authenticated WebUI connection."""
|
|
||||||
if not trusted_webui:
|
|
||||||
raise TemporaryChatError("access_denied")
|
|
||||||
if self._sessions is None:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
chat_id = str(uuid.uuid4())
|
|
||||||
session = self._sessions.get_or_create_transient(
|
|
||||||
self._session_key(chat_id),
|
|
||||||
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
|
|
||||||
)
|
|
||||||
if session.policy.persist:
|
|
||||||
raise RuntimeError("Temporary Chat must use a non-persistent session policy")
|
|
||||||
self._owners[chat_id] = owner
|
|
||||||
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
|
|
||||||
self._active_sessions[chat_id] = session
|
|
||||||
self._known_transient_chat_ids.add(chat_id)
|
|
||||||
return chat_id
|
|
||||||
|
|
||||||
def message_policy(
|
|
||||||
self,
|
|
||||||
owner: object,
|
|
||||||
chat_id: str,
|
|
||||||
content: str,
|
|
||||||
) -> TemporaryChatMessagePolicy | None:
|
|
||||||
"""Return Temporary Chat rules, or ``None`` for an ordinary chat."""
|
|
||||||
if not self._cached_session_is_transient(chat_id):
|
|
||||||
if chat_id in self._known_transient_chat_ids:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
return None
|
|
||||||
if self._owners.get(chat_id) is not owner or self._sessions is None:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
|
||||||
if session is None:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else ""
|
|
||||||
if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS:
|
|
||||||
raise TemporaryChatError("temporary_chat_command_rejected")
|
|
||||||
|
|
||||||
return TemporaryChatMessagePolicy(
|
|
||||||
session_key=self._session_key(chat_id),
|
|
||||||
workspace_scope=self._workspaces.restricted_default_scope(),
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_attach(self, chat_id: str) -> None:
|
|
||||||
"""Reject attempts to recover a non-persistent session."""
|
|
||||||
if not self._cached_session_is_transient(chat_id):
|
|
||||||
if chat_id in self._known_transient_chat_ids:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
return
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
def validate_workspace_update(self, chat_id: str) -> None:
|
|
||||||
"""Prevent non-persistent sessions from acquiring durable workspace state."""
|
|
||||||
if self._cached_session_is_transient(chat_id):
|
|
||||||
raise TemporaryChatError("temporary_chat_workspace_rejected")
|
|
||||||
if chat_id in self._known_transient_chat_ids:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None:
|
|
||||||
if not paths:
|
|
||||||
return
|
|
||||||
if self._owners.get(chat_id) is not owner:
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
self._media_paths.setdefault(chat_id, set()).update(paths)
|
|
||||||
|
|
||||||
def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]:
|
|
||||||
return tuple(self._owner_chat_ids.get(owner, ()))
|
|
||||||
|
|
||||||
def owns(self, owner: object, chat_id: str) -> bool:
|
|
||||||
return self._owners.get(chat_id) is owner
|
|
||||||
|
|
||||||
def should_persist_transcript(self, chat_id: str) -> bool:
|
|
||||||
"""Apply the session policy and retain it for late events after disposal."""
|
|
||||||
return (
|
|
||||||
not self._cached_session_is_transient(chat_id)
|
|
||||||
and chat_id not in self._known_transient_chat_ids
|
|
||||||
)
|
|
||||||
|
|
||||||
def _discard_media(self, chat_id: str) -> None:
|
|
||||||
for raw_path in self._media_paths.pop(chat_id, set()):
|
|
||||||
try:
|
|
||||||
Path(raw_path).unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
self._logger.warning("failed to remove a temporary WebUI attachment")
|
|
||||||
|
|
||||||
def _forget_owner(self, owner: object, chat_id: str) -> None:
|
|
||||||
self._owners.pop(chat_id, None)
|
|
||||||
chat_ids = self._owner_chat_ids.get(owner)
|
|
||||||
if chat_ids is None:
|
|
||||||
return
|
|
||||||
chat_ids.discard(chat_id)
|
|
||||||
if not chat_ids:
|
|
||||||
self._owner_chat_ids.pop(owner, None)
|
|
||||||
|
|
||||||
async def discard(self, owner: object, chat_id: str) -> None:
|
|
||||||
"""Forget one owned chat and cancel any active work through the message bus."""
|
|
||||||
if (
|
|
||||||
not self._cached_session_is_transient(chat_id)
|
|
||||||
or self._owners.get(chat_id) is not owner
|
|
||||||
):
|
|
||||||
raise TemporaryChatError("temporary_chat_unavailable")
|
|
||||||
|
|
||||||
session_key = self._session_key(chat_id)
|
|
||||||
self._forget_owner(owner, chat_id)
|
|
||||||
self._active_sessions.pop(chat_id, None)
|
|
||||||
self._discard_media(chat_id)
|
|
||||||
if self._sessions is not None:
|
|
||||||
self._sessions.invalidate(session_key)
|
|
||||||
await self._bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel=self._channel_name,
|
|
||||||
sender_id="webui",
|
|
||||||
chat_id=chat_id,
|
|
||||||
content="",
|
|
||||||
metadata={
|
|
||||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
},
|
|
||||||
session_key_override=session_key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Release process-local resources during gateway shutdown."""
|
|
||||||
for chat_id in tuple(self._owners):
|
|
||||||
self._discard_media(chat_id)
|
|
||||||
if self._sessions is not None:
|
|
||||||
self._sessions.invalidate(self._session_key(chat_id))
|
|
||||||
self._owners.clear()
|
|
||||||
self._owner_chat_ids.clear()
|
|
||||||
self._active_sessions.clear()
|
|
||||||
self._known_transient_chat_ids.clear()
|
|
||||||
@@ -1313,6 +1313,21 @@ def _recover_incomplete_turns(
|
|||||||
return recovered
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
|
def recover_incomplete_turns_from_session(
|
||||||
|
lines: list[dict[str, Any]],
|
||||||
|
session_messages: list[dict[str, Any]] | None,
|
||||||
|
*,
|
||||||
|
session_key: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Recover marked transcript answers only when one durable session turn matches."""
|
||||||
|
if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines):
|
||||||
|
return lines
|
||||||
|
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||||
|
if not session_turns:
|
||||||
|
return lines
|
||||||
|
return _recover_incomplete_turns(lines, session_turns)
|
||||||
|
|
||||||
|
|
||||||
def _with_backfilled_user(
|
def _with_backfilled_user(
|
||||||
records: list[dict[str, Any]],
|
records: list[dict[str, Any]],
|
||||||
user_event: dict[str, Any],
|
user_event: dict[str, Any],
|
||||||
@@ -1350,6 +1365,20 @@ def _inject_missing_user_events(
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def inject_missing_user_events_from_session(
|
||||||
|
session_key: str,
|
||||||
|
lines: list[dict[str, Any]],
|
||||||
|
session_messages: list[dict[str, Any]] | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
|
||||||
|
if not lines or not session_messages or not _needs_user_event_backfill(lines):
|
||||||
|
return lines
|
||||||
|
session_turns = _session_backfill_turns(session_key, session_messages)
|
||||||
|
if not session_turns:
|
||||||
|
return lines
|
||||||
|
return _inject_missing_user_events(lines, session_turns)
|
||||||
|
|
||||||
|
|
||||||
def _format_tool_call_trace(call: Any) -> str | None:
|
def _format_tool_call_trace(call: Any) -> str | None:
|
||||||
if not call or not isinstance(call, dict):
|
if not call or not isinstance(call, dict):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import time
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from packaging.version import InvalidVersion, Version
|
|
||||||
|
|
||||||
from nanobot import __version__
|
from nanobot import __version__
|
||||||
|
|
||||||
@@ -43,13 +42,7 @@ def check_for_update() -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
_cache = (now, latest)
|
_cache = (now, latest)
|
||||||
|
|
||||||
if not isinstance(latest, str) or not latest:
|
if not latest or latest == __version__:
|
||||||
return None
|
|
||||||
try:
|
|
||||||
if Version(latest) <= Version(__version__):
|
|
||||||
return None
|
|
||||||
except InvalidVersion:
|
|
||||||
logger.debug("PyPI returned an invalid nanobot version: %r", latest)
|
|
||||||
return None
|
return None
|
||||||
return {
|
return {
|
||||||
"currentVersion": __version__,
|
"currentVersion": __version__,
|
||||||
|
|||||||
@@ -191,14 +191,6 @@ class WebUIWorkspaceController:
|
|||||||
self._default_restrict_to_workspace,
|
self._default_restrict_to_workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
def restricted_default_scope(self) -> WorkspaceScope:
|
|
||||||
"""Return the default workspace with access restricted for this request."""
|
|
||||||
return build_workspace_scope(
|
|
||||||
self._default_workspace,
|
|
||||||
"restricted",
|
|
||||||
source_channel=_WEBUI_SCOPE_CHANNEL,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _scope_from_metadata_value(
|
def _scope_from_metadata_value(
|
||||||
self,
|
self,
|
||||||
raw_scope: object,
|
raw_scope: object,
|
||||||
|
|||||||
+59
-175
@@ -17,18 +17,19 @@ import time
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
from urllib.parse import quote, unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from websockets.datastructures import Headers
|
|
||||||
from websockets.http11 import Request as WsRequest
|
from websockets.http11 import Request as WsRequest
|
||||||
from websockets.http11 import Response
|
from websockets.http11 import Response
|
||||||
|
|
||||||
from nanobot.command.builtin import builtin_command_palette
|
from nanobot.command.builtin import builtin_command_palette
|
||||||
from nanobot.cron.session_turns import is_bound_cron_job
|
from nanobot.cron.session_turns import is_bound_cron_job
|
||||||
from nanobot.cron.types import CronJob, CronSchedule
|
from nanobot.cron.types import CronJob, CronSchedule
|
||||||
|
from nanobot.runtime_context import public_history_messages
|
||||||
from nanobot.security.workspace_access import WorkspaceScope
|
from nanobot.security.workspace_access import WorkspaceScope
|
||||||
from nanobot.triggers.local_types import LocalTrigger
|
from nanobot.triggers.local_types import LocalTrigger
|
||||||
|
from nanobot.utils.subagent_channel_display import scrub_subagent_messages_for_channel
|
||||||
from nanobot.webui.file_preview import (
|
from nanobot.webui.file_preview import (
|
||||||
WebUIFilePreviewError,
|
WebUIFilePreviewError,
|
||||||
file_preview_availability_payload,
|
file_preview_availability_payload,
|
||||||
@@ -119,60 +120,7 @@ from nanobot.webui.transcript import build_webui_thread_response
|
|||||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||||
|
|
||||||
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
_SLOW_WEBUI_HTTP_LOG_MS = 1_000
|
||||||
_WEBUI_MUTATION_PAYLOAD_ATTR = "_nanobot_webui_mutation_payload"
|
_AUTOMATION_VALUES_HEADER = "X-Nanobot-Automation-Values"
|
||||||
_WEBUI_MUTATION_REQUEST_ATTR = "_nanobot_webui_mutation_request"
|
|
||||||
|
|
||||||
_WEBUI_MUTATION_PATHS = {
|
|
||||||
"automation.enable": "/api/webui/automations/enable",
|
|
||||||
"automation.disable": "/api/webui/automations/disable",
|
|
||||||
"automation.delete": "/api/webui/automations/delete",
|
|
||||||
"automation.run": "/api/webui/automations/run",
|
|
||||||
"automation.update": "/api/webui/automations/update",
|
|
||||||
"skill.install": "/api/webui/skills/install",
|
|
||||||
"skill.update": "/api/webui/skills/update",
|
|
||||||
"skill.delete": "/api/webui/skills/delete",
|
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
|
||||||
"settings.agent.update": "/api/settings/update",
|
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
|
||||||
"settings.model_configuration.delete": "/api/settings/model-configurations/delete",
|
|
||||||
"settings.model_configuration.migrate": "/api/settings/model-configurations/migrate",
|
|
||||||
"settings.model_call_order.update": "/api/settings/model-call-order/update",
|
|
||||||
"settings.provider.update": "/api/settings/provider/update",
|
|
||||||
"settings.provider.create": "/api/settings/provider/create",
|
|
||||||
"settings.provider.oauth_login": "/api/settings/provider/oauth-login",
|
|
||||||
"settings.provider.oauth_complete": "/api/settings/provider/oauth-login/complete",
|
|
||||||
"settings.provider.oauth_logout": "/api/settings/provider/oauth-logout",
|
|
||||||
"settings.web_search.update": "/api/settings/web-search/update",
|
|
||||||
"settings.api_service.start": "/api/settings/api-service/start",
|
|
||||||
"settings.api_service.stop": "/api/settings/api-service/stop",
|
|
||||||
"settings.image_generation.update": "/api/settings/image-generation/update",
|
|
||||||
"settings.transcription.update": "/api/settings/transcription/update",
|
|
||||||
"settings.network_safety.update": "/api/settings/network-safety/update",
|
|
||||||
"settings.cli_app.install": "/api/settings/cli-apps/install",
|
|
||||||
"settings.cli_app.update": "/api/settings/cli-apps/update",
|
|
||||||
"settings.cli_app.uninstall": "/api/settings/cli-apps/uninstall",
|
|
||||||
"settings.cli_app.test": "/api/settings/cli-apps/test",
|
|
||||||
"settings.feature.enable": "/api/settings/nanobot-features/enable",
|
|
||||||
"settings.feature.disable": "/api/settings/nanobot-features/disable",
|
|
||||||
"settings.channel.validate": "/api/settings/channels/validate",
|
|
||||||
"settings.channel.configure": "/api/settings/channels/configure",
|
|
||||||
"settings.pairing.approve": "/api/settings/pairing/approve",
|
|
||||||
"settings.pairing.deny": "/api/settings/pairing/deny",
|
|
||||||
"settings.mcp.enable": "/api/settings/mcp-presets/enable",
|
|
||||||
"settings.mcp.remove": "/api/settings/mcp-presets/remove",
|
|
||||||
"settings.mcp.test": "/api/settings/mcp-presets/test",
|
|
||||||
"settings.mcp.custom": "/api/settings/mcp-presets/custom",
|
|
||||||
"settings.mcp.import": "/api/settings/mcp-presets/import",
|
|
||||||
"settings.mcp.import_cursor": "/api/settings/mcp-presets/import-cursor",
|
|
||||||
"settings.mcp.tools": "/api/settings/mcp-presets/tools",
|
|
||||||
}
|
|
||||||
|
|
||||||
_WEBUI_CHANNEL_CONNECT_ACTIONS = {
|
|
||||||
"settings.channel.connect.start": "start",
|
|
||||||
"settings.channel.connect.poll": "poll",
|
|
||||||
"settings.channel.connect.cancel": "cancel",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
# Fix for #5190: On Windows, mimetypes.guess_type() reads the registry key
|
||||||
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
# HKEY_CLASSES_ROOT\.js\Content Type, which is commonly set to 'text/plain'
|
||||||
@@ -204,7 +152,6 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
from nanobot.triggers.local_store import LocalTriggerStore
|
from nanobot.triggers.local_store import LocalTriggerStore
|
||||||
from nanobot.webui.settings_services import WebUISettingsServices
|
|
||||||
|
|
||||||
def _decode_api_key(raw_key: str) -> str | None:
|
def _decode_api_key(raw_key: str) -> str | None:
|
||||||
key = unquote(raw_key)
|
key = unquote(raw_key)
|
||||||
@@ -214,33 +161,6 @@ def _decode_api_key(raw_key: str) -> str | None:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
def _mutation_payload(request: WsRequest) -> dict[str, Any] | None:
|
|
||||||
payload = getattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, None)
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return None
|
|
||||||
return cast(dict[str, Any], payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _request_query(request: WsRequest) -> dict[str, list[str]]:
|
|
||||||
payload = _mutation_payload(request)
|
|
||||||
if payload is None:
|
|
||||||
return _parse_query(request.path)
|
|
||||||
query: dict[str, list[str]] = {}
|
|
||||||
for key, value in payload.items():
|
|
||||||
if not key:
|
|
||||||
continue
|
|
||||||
if isinstance(value, bool):
|
|
||||||
text = "true" if value else "false"
|
|
||||||
elif value is None:
|
|
||||||
text = ""
|
|
||||||
elif isinstance(value, (dict, list)):
|
|
||||||
text = json.dumps(value, ensure_ascii=False, separators=(",", ":"))
|
|
||||||
else:
|
|
||||||
text = str(value)
|
|
||||||
query[key] = [text]
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
def _default_model_name_from_config() -> str | None:
|
def _default_model_name_from_config() -> str | None:
|
||||||
try:
|
try:
|
||||||
from nanobot.config.loader import load_config
|
from nanobot.config.loader import load_config
|
||||||
@@ -293,7 +213,6 @@ class GatewayHTTPHandler:
|
|||||||
media: WebUIMediaGateway,
|
media: WebUIMediaGateway,
|
||||||
ingress: WebUIIngressPolicy,
|
ingress: WebUIIngressPolicy,
|
||||||
workspaces: WebUIWorkspaceController,
|
workspaces: WebUIWorkspaceController,
|
||||||
settings: WebUISettingsServices,
|
|
||||||
skills_workspace_path: Path,
|
skills_workspace_path: Path,
|
||||||
disabled_skills: set[str] | None = None,
|
disabled_skills: set[str] | None = None,
|
||||||
cron_service: CronService | None = None,
|
cron_service: CronService | None = None,
|
||||||
@@ -314,7 +233,6 @@ class GatewayHTTPHandler:
|
|||||||
self.media = media
|
self.media = media
|
||||||
self.ingress = ingress
|
self.ingress = ingress
|
||||||
self.workspaces = workspaces
|
self.workspaces = workspaces
|
||||||
self.settings = settings
|
|
||||||
self.skills_workspace_path = skills_workspace_path
|
self.skills_workspace_path = skills_workspace_path
|
||||||
self.disabled_skills: set[str] = (
|
self.disabled_skills: set[str] = (
|
||||||
disabled_skills if disabled_skills is not None else set()
|
disabled_skills if disabled_skills is not None else set()
|
||||||
@@ -333,7 +251,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
self._capabilities = _rc(runtime_surface, runtime_capabilities_overrides or {})
|
||||||
self.settings_routes = WebUISettingsRouter(
|
self.settings_routes = WebUISettingsRouter(
|
||||||
settings=settings,
|
|
||||||
bus=bus,
|
bus=bus,
|
||||||
logger=self._log,
|
logger=self._log,
|
||||||
check_api_token=self.check_api_token,
|
check_api_token=self.check_api_token,
|
||||||
@@ -370,86 +287,11 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if self._is_webui_mutation_path(got):
|
|
||||||
return _http_error(
|
|
||||||
405,
|
|
||||||
"WebUI mutations require an authenticated WebSocket",
|
|
||||||
)
|
|
||||||
response = await self._dispatch_resolved(connection, request, got)
|
response = await self._dispatch_resolved(connection, request, got)
|
||||||
return response
|
return response
|
||||||
finally:
|
finally:
|
||||||
self._log_slow_http(got, response, started)
|
self._log_slow_http(got, response, started)
|
||||||
|
|
||||||
async def dispatch_webui_mutation(
|
|
||||||
self,
|
|
||||||
connection: Any,
|
|
||||||
action: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> Response:
|
|
||||||
"""Run one explicitly allowlisted mutation for an authenticated WebUI socket."""
|
|
||||||
path = self._webui_mutation_path(action, payload)
|
|
||||||
if isinstance(path, Response):
|
|
||||||
return path
|
|
||||||
|
|
||||||
source_request = getattr(connection, "request", None)
|
|
||||||
source_headers = getattr(source_request, "headers", None)
|
|
||||||
if source_headers is None:
|
|
||||||
headers = Headers()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
headers = Headers(source_headers.raw_items())
|
|
||||||
except (AttributeError, TypeError):
|
|
||||||
try:
|
|
||||||
headers = Headers(source_headers)
|
|
||||||
except TypeError:
|
|
||||||
headers = Headers()
|
|
||||||
request = WsRequest(path, headers)
|
|
||||||
setattr(request, "_nanobot_trusted_proxy_authenticated", True)
|
|
||||||
setattr(request, _WEBUI_MUTATION_REQUEST_ATTR, True)
|
|
||||||
setattr(request, _WEBUI_MUTATION_PAYLOAD_ATTR, dict(payload))
|
|
||||||
response = await self._dispatch_resolved(connection, request, path)
|
|
||||||
if isinstance(response, Response):
|
|
||||||
return response
|
|
||||||
return _http_error(404, "WebUI mutation action not found")
|
|
||||||
|
|
||||||
def _is_webui_mutation_path(self, path: str) -> bool:
|
|
||||||
if self.settings_routes.is_mutation_path(path):
|
|
||||||
return True
|
|
||||||
if re.match(r"^/api/sessions/[^/]+/delete$", path):
|
|
||||||
return True
|
|
||||||
if re.match(r"^/api/webui/automations/(enable|disable|delete|run|update)$", path):
|
|
||||||
return True
|
|
||||||
return path in {
|
|
||||||
"/api/webui/skills/install",
|
|
||||||
"/api/webui/skills/update",
|
|
||||||
"/api/webui/skills/delete",
|
|
||||||
"/api/webui/sidebar-state/update",
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _webui_mutation_path(
|
|
||||||
action: str,
|
|
||||||
payload: dict[str, Any],
|
|
||||||
) -> str | Response:
|
|
||||||
path = _WEBUI_MUTATION_PATHS.get(action)
|
|
||||||
if path is not None:
|
|
||||||
return path
|
|
||||||
if action == "session.delete":
|
|
||||||
key = payload.get("key")
|
|
||||||
if not isinstance(key, str) or not key.strip():
|
|
||||||
return _http_error(400, "missing session key")
|
|
||||||
return f"/api/sessions/{quote(key, safe='')}/delete"
|
|
||||||
connect_action = _WEBUI_CHANNEL_CONNECT_ACTIONS.get(action)
|
|
||||||
if connect_action is not None:
|
|
||||||
channel = payload.get("channel")
|
|
||||||
if not isinstance(channel, str) or re.fullmatch(
|
|
||||||
r"[A-Za-z0-9_-]{1,64}",
|
|
||||||
channel,
|
|
||||||
) is None:
|
|
||||||
return _http_error(400, "invalid channel name")
|
|
||||||
return f"/api/settings/channels/{channel}/connect/{connect_action}"
|
|
||||||
return _http_error(404, "unknown WebUI mutation action")
|
|
||||||
|
|
||||||
async def _dispatch_resolved(
|
async def _dispatch_resolved(
|
||||||
self,
|
self,
|
||||||
connection: Any,
|
connection: Any,
|
||||||
@@ -620,6 +462,10 @@ class GatewayHTTPHandler:
|
|||||||
# -- Session routes -----------------------------------------------------
|
# -- Session routes -----------------------------------------------------
|
||||||
|
|
||||||
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
async def _dispatch_session_routes(self, request: WsRequest, got: str) -> Response | None:
|
||||||
|
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
|
||||||
|
if m:
|
||||||
|
return self._handle_session_messages(request, m.group(1))
|
||||||
|
|
||||||
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
|
m = re.match(r"^/api/sessions/([^/]+)/webui-thread$", got)
|
||||||
if m:
|
if m:
|
||||||
return self._handle_webui_thread_get(request, m.group(1))
|
return self._handle_webui_thread_get(request, m.group(1))
|
||||||
@@ -681,6 +527,34 @@ class GatewayHTTPHandler:
|
|||||||
cleaned.append(row)
|
cleaned.append(row)
|
||||||
return {"sessions": cleaned}
|
return {"sessions": cleaned}
|
||||||
|
|
||||||
|
def _handle_session_messages(self, request: WsRequest, key: str) -> Response:
|
||||||
|
if not self.check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
if self.session_manager is None:
|
||||||
|
return _http_error(503, "session manager unavailable")
|
||||||
|
decoded_key = _decode_api_key(key)
|
||||||
|
if decoded_key is None:
|
||||||
|
return _http_error(400, "invalid session key")
|
||||||
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
|
return _http_error(404, "session not found")
|
||||||
|
data = self.session_manager.read_session_file(decoded_key)
|
||||||
|
if data is None:
|
||||||
|
return _http_error(404, "session not found")
|
||||||
|
messages = data.get("messages")
|
||||||
|
if isinstance(messages, list):
|
||||||
|
session_messages = cast(list[dict[str, Any]], messages)
|
||||||
|
scrub_subagent_messages_for_channel(session_messages)
|
||||||
|
raw_session_messages = cast(list[Any], messages)
|
||||||
|
data["messages"] = public_history_messages(
|
||||||
|
[
|
||||||
|
cast(dict[str, Any], message)
|
||||||
|
for message in raw_session_messages
|
||||||
|
if isinstance(message, dict)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self.media.augment_media_urls(data)
|
||||||
|
return _http_json_response(data)
|
||||||
|
|
||||||
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
|
def _handle_webui_thread_get(self, request: WsRequest, key: str) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
@@ -806,7 +680,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(400, "invalid session key")
|
return _http_error(400, "invalid session key")
|
||||||
if not _is_websocket_channel_session_key(decoded_key):
|
if not _is_websocket_channel_session_key(decoded_key):
|
||||||
return _http_error(404, "session not found")
|
return _http_error(404, "session not found")
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
delete_automations = (_query_first(query, "delete_automations") or "").lower()
|
||||||
automation_jobs = session_automation_jobs(
|
automation_jobs = session_automation_jobs(
|
||||||
self.cron_service,
|
self.cron_service,
|
||||||
@@ -902,7 +776,7 @@ class GatewayHTTPHandler:
|
|||||||
if self.cron_service is None and self.local_trigger_store is None:
|
if self.cron_service is None and self.local_trigger_store is None:
|
||||||
return _http_error(503, "automation service unavailable")
|
return _http_error(503, "automation service unavailable")
|
||||||
|
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
job_id = (_query_first(query, "id") or _query_first(query, "job_id") or "").strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return _http_error(400, "missing automation id")
|
return _http_error(400, "missing automation id")
|
||||||
@@ -1134,7 +1008,7 @@ class GatewayHTTPHandler:
|
|||||||
if self._skill_install_lock.locked():
|
if self._skill_install_lock.locked():
|
||||||
return _http_error(409, "another skill installation is already in progress")
|
return _http_error(409, "another skill installation is already in progress")
|
||||||
|
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
provider = _query_first(query, "provider") or "skills_sh"
|
provider = _query_first(query, "provider") or "skills_sh"
|
||||||
source = _query_first(query, "source") or ""
|
source = _query_first(query, "source") or ""
|
||||||
skill_id = _query_first(query, "skill") or ""
|
skill_id = _query_first(query, "skill") or ""
|
||||||
@@ -1175,7 +1049,7 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
def _handle_webui_skill_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
query = _request_query(request)
|
query = _parse_query(request.path)
|
||||||
name = _query_first(query, "name") or ""
|
name = _query_first(query, "name") or ""
|
||||||
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
raw_enabled = (_query_first(query, "enabled") or "").lower()
|
||||||
if raw_enabled not in {"true", "false"}:
|
if raw_enabled not in {"true", "false"}:
|
||||||
@@ -1207,7 +1081,7 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
if not _is_local_browser_request(connection, request.headers):
|
if not _is_local_browser_request(connection, request.headers):
|
||||||
return _http_error(403, "remote skill deletion is disabled")
|
return _http_error(403, "remote skill deletion is disabled")
|
||||||
name = _query_first(_request_query(request), "name") or ""
|
name = _query_first(_parse_query(request.path), "name") or ""
|
||||||
try:
|
try:
|
||||||
action = delete_webui_skill(
|
action = delete_webui_skill(
|
||||||
self.skills_workspace_path,
|
self.skills_workspace_path,
|
||||||
@@ -1254,14 +1128,18 @@ class GatewayHTTPHandler:
|
|||||||
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
def _handle_webui_sidebar_state_update(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
payload = _mutation_payload(request)
|
query = _parse_query(request.path)
|
||||||
state_value = payload.get("state") if payload is not None else None
|
raw_state = _query_first(query, "state")
|
||||||
if state_value is None:
|
if raw_state is None:
|
||||||
return _http_error(400, "missing state")
|
return _http_error(400, "missing state")
|
||||||
if not isinstance(state_value, dict):
|
try:
|
||||||
|
decoded = json.loads(raw_state)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return _http_error(400, "state must be JSON")
|
||||||
|
if not isinstance(decoded, dict):
|
||||||
return _http_error(400, "state must be an object")
|
return _http_error(400, "state must be an object")
|
||||||
try:
|
try:
|
||||||
state = write_webui_sidebar_state(cast(dict[str, Any], state_value))
|
state = write_webui_sidebar_state(cast(dict[str, Any], decoded))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return _http_error(400, str(e))
|
return _http_error(400, str(e))
|
||||||
except OSError:
|
except OSError:
|
||||||
@@ -1330,10 +1208,16 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
|
|
||||||
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
def _automation_values_from_request(request: WsRequest) -> dict[str, Any] | None:
|
||||||
payload = _mutation_payload(request)
|
raw = _case_insensitive_header(request.headers, _AUTOMATION_VALUES_HEADER)
|
||||||
if payload is None or "values" not in payload:
|
if not raw:
|
||||||
return {}
|
return {}
|
||||||
values = payload.get("values")
|
try:
|
||||||
|
values = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
values = json.loads(unquote(raw))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
return cast(dict[str, Any], values) if isinstance(values, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ dependencies = [
|
|||||||
"readability-lxml>=0.8.4,<1.0.0",
|
"readability-lxml>=0.8.4,<1.0.0",
|
||||||
"lxml-html-clean>=0.4.0,<1.0.0",
|
"lxml-html-clean>=0.4.0,<1.0.0",
|
||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"qrcode[pil]>=8.0",
|
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"questionary>=2.0.0,<3.0.0",
|
"questionary>=2.0.0,<3.0.0",
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ def _make_fake_compact(
|
|||||||
track_archived: list | None = None,
|
track_archived: list | None = None,
|
||||||
track_count: bool = False,
|
track_count: bool = False,
|
||||||
):
|
):
|
||||||
|
from nanobot.session.manager import Session as _Session
|
||||||
|
|
||||||
state = {"count": 0}
|
state = {"count": 0}
|
||||||
|
|
||||||
async def _fake_compact(key: str, *, runtime, max_suffix: int = 8) -> str:
|
async def _fake_compact(key: str, *, runtime, max_suffix: int = 8) -> str:
|
||||||
@@ -90,8 +92,25 @@ def _make_fake_compact(
|
|||||||
if not tail:
|
if not tail:
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return ""
|
return ""
|
||||||
archive_end = session.last_consolidated + len(tail)
|
|
||||||
archive_msgs = tail
|
probe = _Session(
|
||||||
|
key=session.key,
|
||||||
|
messages=tail.copy(),
|
||||||
|
created_at=session.created_at,
|
||||||
|
updated_at=session.updated_at,
|
||||||
|
metadata={},
|
||||||
|
last_consolidated=0,
|
||||||
|
)
|
||||||
|
result = probe.retain_recent_legal_suffix(
|
||||||
|
max_suffix,
|
||||||
|
extend_to_user=True,
|
||||||
|
)
|
||||||
|
visible_suffix = probe.messages
|
||||||
|
archive_msgs = result.dropped
|
||||||
|
|
||||||
|
if not archive_msgs:
|
||||||
|
loop.sessions.save(session)
|
||||||
|
return ""
|
||||||
|
|
||||||
last_active = session.updated_at
|
last_active = session.updated_at
|
||||||
s = summary
|
s = summary
|
||||||
@@ -107,7 +126,7 @@ def _make_fake_compact(
|
|||||||
"last_active": last_active.isoformat(),
|
"last_active": last_active.isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
session.last_consolidated = archive_end
|
session.last_consolidated = len(session.messages) - len(visible_suffix)
|
||||||
loop.sessions.save(session)
|
loop.sessions.save(session)
|
||||||
return s
|
return s
|
||||||
|
|
||||||
@@ -346,7 +365,7 @@ class TestAutoCompact:
|
|||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_auto_compact_archives_full_tail_without_deleting_history(self, tmp_path):
|
async def test_auto_compact_archives_prefix_without_deleting_history(self, tmp_path):
|
||||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||||
session = loop.sessions.get_or_create("cli:test")
|
session = loop.sessions.get_or_create("cli:test")
|
||||||
_add_turns(session, 6)
|
_add_turns(session, 6)
|
||||||
@@ -359,7 +378,7 @@ class TestAutoCompact:
|
|||||||
|
|
||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
assert len(archived_messages) == 12
|
assert len(archived_messages) == 4
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 12
|
assert len(session_after.messages) == 12
|
||||||
assert session_after.messages[0]["content"] == "msg user 0"
|
assert session_after.messages[0]["content"] == "msg user 0"
|
||||||
@@ -454,7 +473,7 @@ class TestAutoCompact:
|
|||||||
|
|
||||||
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
await loop.auto_compact._archive("cli:test", runtime=loop.llm_runtime())
|
||||||
|
|
||||||
assert len(archived_messages) == 10
|
assert len(archived_messages) == 2
|
||||||
await loop.close_mcp()
|
await loop.close_mcp()
|
||||||
|
|
||||||
|
|
||||||
@@ -496,7 +515,7 @@ class TestAutoCompactIdleDetection:
|
|||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert len(archived_messages) == 12
|
assert len(archived_messages) == 4
|
||||||
assert any(m["content"] == "old user 0" for m in session_after.messages)
|
assert any(m["content"] == "old user 0" for m in session_after.messages)
|
||||||
assert not any(
|
assert not any(
|
||||||
m["content"] == "old user 0"
|
m["content"] == "old user 0"
|
||||||
@@ -705,7 +724,7 @@ class TestAutoCompactEdgeCases:
|
|||||||
await loop._process_message(msg)
|
await loop._process_message(msg)
|
||||||
|
|
||||||
session_after = loop.sessions.get_or_create("cli:test")
|
session_after = loop.sessions.get_or_create("cli:test")
|
||||||
assert [message["content"] for message in archived_messages] == ["previous message"]
|
assert archived_messages == []
|
||||||
assert any(m["content"] == "previous message" for m in session_after.messages)
|
assert any(m["content"] == "previous message" for m in session_after.messages)
|
||||||
assert any(m["content"] == "interrupted response" for m in session_after.messages)
|
assert any(m["content"] == "interrupted response" for m in session_after.messages)
|
||||||
|
|
||||||
@@ -893,7 +912,7 @@ class TestProactiveAutoCompact:
|
|||||||
assert len(session_after.get_history(max_messages=10)) == (
|
assert len(session_after.get_history(max_messages=10)) == (
|
||||||
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
loop.auto_compact._RECENT_SUFFIX_MESSAGES
|
||||||
)
|
)
|
||||||
assert len(archived_messages) == 10
|
assert len(archived_messages) == 2
|
||||||
entry = loop.auto_compact._summaries.get("cli:test")
|
entry = loop.auto_compact._summaries.get("cli:test")
|
||||||
assert entry is not None
|
assert entry is not None
|
||||||
assert entry[0] == "User chatted about old things."
|
assert entry[0] == "User chatted about old things."
|
||||||
|
|||||||
@@ -405,37 +405,13 @@ class TestCheckExpired:
|
|||||||
scheduler.assert_not_called()
|
scheduler.assert_not_called()
|
||||||
assert "dream:20260602-155256" not in ac._archiving
|
assert "dream:20260602-155256" not in ac._archiving
|
||||||
|
|
||||||
def test_short_unarchived_session_schedules(self):
|
def test_already_trimmed_session_skips(self):
|
||||||
"""A short idle session still needs an archive entry for Dream."""
|
"""Expired session with no removable tail should not be re-scheduled."""
|
||||||
ac = _make_autocompact(ttl=15)
|
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
|
||||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
|
||||||
session = _make_session("cli:short", updated_at=last_active)
|
|
||||||
_add_turns(session, 2)
|
|
||||||
mock_sm.list_sessions.return_value = [
|
|
||||||
{"key": "cli:short", "updated_at": last_active.isoformat()},
|
|
||||||
]
|
|
||||||
mock_sm.get_or_create.return_value = session
|
|
||||||
ac.sessions = mock_sm
|
|
||||||
|
|
||||||
scheduled = []
|
|
||||||
|
|
||||||
def scheduler(coro):
|
|
||||||
scheduled.append(coro)
|
|
||||||
coro.close()
|
|
||||||
|
|
||||||
ac.check_expired(scheduler, _runtime)
|
|
||||||
|
|
||||||
assert len(scheduled) == 1
|
|
||||||
assert ac._archiving == {"cli:short"}
|
|
||||||
|
|
||||||
def test_fully_archived_session_skips(self):
|
|
||||||
ac = _make_autocompact(ttl=15)
|
ac = _make_autocompact(ttl=15)
|
||||||
mock_sm = MagicMock(spec=SessionManager)
|
mock_sm = MagicMock(spec=SessionManager)
|
||||||
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
last_active = datetime(2026, 1, 1, 10, 0, 0)
|
||||||
session = _make_session("cli:done", updated_at=last_active)
|
session = _make_session("cli:done", updated_at=last_active)
|
||||||
_add_turns(session, 2)
|
_add_turns(session, 2)
|
||||||
session.last_consolidated = len(session.messages)
|
|
||||||
mock_sm.list_sessions.return_value = [
|
mock_sm.list_sessions.return_value = [
|
||||||
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
{"key": "cli:done", "updated_at": last_active.isoformat()},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -391,25 +391,6 @@ class TestConsolidatorTokenBudget:
|
|||||||
assert len(captured["history"]) == 160
|
assert len(captured["history"]) == 160
|
||||||
assert captured["history"][0]["content"].endswith("msg-0")
|
assert captured["history"][0]["content"].endswith("msg-0")
|
||||||
|
|
||||||
async def test_estimate_includes_recent_archived_replay(self, consolidator, runtime):
|
|
||||||
session = Session(key="test:archived-replay")
|
|
||||||
for i in range(10):
|
|
||||||
session.add_message("user", f"msg-{i}")
|
|
||||||
session.last_consolidated = len(session.messages)
|
|
||||||
|
|
||||||
captured: dict[str, list[dict]] = {}
|
|
||||||
|
|
||||||
def build_messages(**kwargs):
|
|
||||||
captured["history"] = kwargs["history"]
|
|
||||||
return kwargs["history"]
|
|
||||||
|
|
||||||
consolidator._build_messages = build_messages
|
|
||||||
|
|
||||||
consolidator.estimate_session_prompt_tokens(session, runtime=runtime)
|
|
||||||
|
|
||||||
assert len(captured["history"]) == 8
|
|
||||||
assert captured["history"][0]["content"] == "msg-2"
|
|
||||||
|
|
||||||
async def test_replay_window_overflow_is_archived_even_under_token_budget(
|
async def test_replay_window_overflow_is_archived_even_under_token_budget(
|
||||||
self,
|
self,
|
||||||
consolidator,
|
consolidator,
|
||||||
@@ -639,7 +620,7 @@ class TestCompactIdleSession:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_archives_full_tail_preserves_messages_and_replays_recent_suffix(
|
async def test_archives_prefix_preserves_messages_and_hides_prefix(
|
||||||
self, real_consolidator, mock_provider, runtime
|
self, real_consolidator, mock_provider, runtime
|
||||||
):
|
):
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
mock_provider.chat_with_retry.return_value = MagicMock(
|
||||||
@@ -664,7 +645,7 @@ class TestCompactIdleSession:
|
|||||||
reloaded = sessions.get_or_create("cli:test")
|
reloaded = sessions.get_or_create("cli:test")
|
||||||
assert len(reloaded.messages) == 40
|
assert len(reloaded.messages) == 40
|
||||||
assert reloaded.messages[0]["content"] == "user msg 0"
|
assert reloaded.messages[0]["content"] == "user msg 0"
|
||||||
assert reloaded.last_consolidated == 40
|
assert reloaded.last_consolidated == 32
|
||||||
assert reloaded.provider_state is None
|
assert reloaded.provider_state is None
|
||||||
visible = reloaded.get_history(max_messages=40)
|
visible = reloaded.get_history(max_messages=40)
|
||||||
assert len(visible) == 8
|
assert len(visible) == 8
|
||||||
@@ -676,82 +657,6 @@ class TestCompactIdleSession:
|
|||||||
assert "last_active" in meta
|
assert "last_active" in meta
|
||||||
assert reloaded.updated_at == old_ts
|
assert reloaded.updated_at == old_ts
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_short_idle_session_archives_once(
|
|
||||||
self, real_consolidator, mock_provider, store, runtime
|
|
||||||
):
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
|
||||||
content="Short summary.", finish_reason="stop"
|
|
||||||
)
|
|
||||||
sessions = real_consolidator.sessions
|
|
||||||
session = sessions.get_or_create("cli:short")
|
|
||||||
session.add_message("user", "hello")
|
|
||||||
session.add_message("assistant", "hi")
|
|
||||||
sessions.save(session)
|
|
||||||
|
|
||||||
first = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
|
|
||||||
second = await real_consolidator.compact_idle_session("cli:short", runtime=runtime)
|
|
||||||
|
|
||||||
assert first == "Short summary."
|
|
||||||
assert second == ""
|
|
||||||
mock_provider.chat_with_retry.assert_awaited_once()
|
|
||||||
assert len(store.read_unprocessed_history(since_cursor=0)) == 1
|
|
||||||
reloaded = sessions.get_or_create("cli:short")
|
|
||||||
assert reloaded.last_consolidated == 2
|
|
||||||
assert [message["content"] for message in reloaded.get_history()] == ["hello", "hi"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_new_messages_advance_existing_archive_progress(
|
|
||||||
self, real_consolidator, mock_provider, runtime
|
|
||||||
):
|
|
||||||
mock_provider.chat_with_retry.return_value = MagicMock(
|
|
||||||
content="Summary.", finish_reason="stop"
|
|
||||||
)
|
|
||||||
sessions = real_consolidator.sessions
|
|
||||||
session = sessions.get_or_create("cli:incremental")
|
|
||||||
session.add_message("user", "first user")
|
|
||||||
session.add_message("assistant", "first assistant")
|
|
||||||
sessions.save(session)
|
|
||||||
|
|
||||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
|
||||||
current = sessions.get_or_create("cli:incremental")
|
|
||||||
current.add_message("user", "second user")
|
|
||||||
current.add_message("assistant", "second assistant")
|
|
||||||
sessions.save(current)
|
|
||||||
await real_consolidator.compact_idle_session("cli:incremental", runtime=runtime)
|
|
||||||
|
|
||||||
assert mock_provider.chat_with_retry.await_count == 2
|
|
||||||
latest_prompt = mock_provider.chat_with_retry.await_args_list[-1].kwargs["messages"][1][
|
|
||||||
"content"
|
|
||||||
]
|
|
||||||
assert "second user" in latest_prompt
|
|
||||||
assert "first user" not in latest_prompt
|
|
||||||
assert sessions.get_or_create("cli:incremental").last_consolidated == 4
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_concurrent_append_remains_unarchived(
|
|
||||||
self, real_consolidator, mock_provider, runtime
|
|
||||||
):
|
|
||||||
sessions = real_consolidator.sessions
|
|
||||||
session = sessions.get_or_create("cli:concurrent")
|
|
||||||
session.add_message("user", "captured user")
|
|
||||||
session.add_message("assistant", "captured assistant")
|
|
||||||
sessions.save(session)
|
|
||||||
|
|
||||||
async def append_during_archive(**_kwargs):
|
|
||||||
current = sessions.get_or_create("cli:concurrent")
|
|
||||||
current.add_message("user", "late user")
|
|
||||||
current.add_message("assistant", "late assistant")
|
|
||||||
return LLMResponse(content="Summary.", finish_reason="stop")
|
|
||||||
|
|
||||||
mock_provider.chat_with_retry.side_effect = append_during_archive
|
|
||||||
|
|
||||||
await real_consolidator.compact_idle_session("cli:concurrent", runtime=runtime)
|
|
||||||
|
|
||||||
reloaded = sessions.get_or_create("cli:concurrent")
|
|
||||||
assert len(reloaded.messages) == 4
|
|
||||||
assert reloaded.last_consolidated == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
async def test_summarizes_retained_suffix_not_just_dropped_prefix(
|
||||||
self, real_consolidator, mock_provider, runtime
|
self, real_consolidator, mock_provider, runtime
|
||||||
@@ -781,10 +686,10 @@ class TestCompactIdleSession:
|
|||||||
assert "CORRECTED_FINAL_RESULT_alpha" in summarized
|
assert "CORRECTED_FINAL_RESULT_alpha" in summarized
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_raw_dumps_full_archive_batch_on_llm_failure(
|
async def test_raw_dumps_only_dropped_messages_on_llm_failure(
|
||||||
self, real_consolidator, mock_provider, store, runtime
|
self, real_consolidator, mock_provider, store, runtime
|
||||||
):
|
):
|
||||||
"""The fallback covers the same full range as successful idle archival."""
|
"""Extra summary context must not enter raw fallback. Regression for #4264."""
|
||||||
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
mock_provider.chat_with_retry.side_effect = RuntimeError("LLM unavailable")
|
||||||
sessions = real_consolidator.sessions
|
sessions = real_consolidator.sessions
|
||||||
session = sessions.get_or_create("cli:rawdrop")
|
session = sessions.get_or_create("cli:rawdrop")
|
||||||
@@ -802,7 +707,7 @@ class TestCompactIdleSession:
|
|||||||
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
raw = "\n".join(e["content"] for e in store.read_unprocessed_history(since_cursor=0))
|
||||||
assert "[RAW]" in raw
|
assert "[RAW]" in raw
|
||||||
assert "user msg 0" in raw
|
assert "user msg 0" in raw
|
||||||
assert "RETAINED_SUFFIX_marker" in raw
|
assert "RETAINED_SUFFIX_marker" not in raw
|
||||||
reloaded = sessions.get_or_create("cli:rawdrop")
|
reloaded = sessions.get_or_create("cli:rawdrop")
|
||||||
assert len(reloaded.messages) == 38
|
assert len(reloaded.messages) == 38
|
||||||
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
|
assert reloaded.messages[-1]["content"] == "RETAINED_SUFFIX_marker"
|
||||||
@@ -900,12 +805,8 @@ class TestCompactIdleSession:
|
|||||||
reloaded = sessions.get_or_create("cli:fail")
|
reloaded = sessions.get_or_create("cli:fail")
|
||||||
assert len(reloaded.messages) == 20
|
assert len(reloaded.messages) == 20
|
||||||
assert reloaded.messages[0]["content"] == "u0"
|
assert reloaded.messages[0]["content"] == "u0"
|
||||||
assert reloaded.last_consolidated == 20
|
assert reloaded.last_consolidated == 16
|
||||||
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
assert [m["content"] for m in reloaded.get_history(max_messages=20)] == [
|
||||||
"u6",
|
|
||||||
"a6",
|
|
||||||
"u7",
|
|
||||||
"a7",
|
|
||||||
"u8",
|
"u8",
|
||||||
"a8",
|
"a8",
|
||||||
"u9",
|
"u9",
|
||||||
@@ -934,10 +835,10 @@ class TestCompactIdleSession:
|
|||||||
assert result == "Tail summary."
|
assert result == "Tail summary."
|
||||||
reloaded = sessions.get_or_create("cli:offset")
|
reloaded = sessions.get_or_create("cli:offset")
|
||||||
assert len(reloaded.messages) == 60
|
assert len(reloaded.messages) == 60
|
||||||
assert reloaded.last_consolidated == 60
|
assert reloaded.last_consolidated == 56
|
||||||
|
|
||||||
# Verify only the unconsolidated tail was processed:
|
# Verify only the unconsolidated tail was processed:
|
||||||
# All 10 unconsolidated messages (50-59) are archived exactly once.
|
# 10 unconsolidated messages (50-59), keep suffix of 4 → archive 6
|
||||||
archived_call = mock_provider.chat_with_retry.call_args
|
archived_call = mock_provider.chat_with_retry.call_args
|
||||||
user_content = archived_call.kwargs["messages"][1]["content"]
|
user_content = archived_call.kwargs["messages"][1]["content"]
|
||||||
# Should contain only tail messages, not early ones
|
# Should contain only tail messages, not early ones
|
||||||
@@ -945,7 +846,7 @@ class TestCompactIdleSession:
|
|||||||
assert "u25" in user_content or "a25" in user_content
|
assert "u25" in user_content or "a25" in user_content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_full_archive_keeps_extended_legal_replay_suffix(
|
async def test_extended_suffix_archives_only_hidden_prefix(
|
||||||
self,
|
self,
|
||||||
real_consolidator,
|
real_consolidator,
|
||||||
mock_provider,
|
mock_provider,
|
||||||
@@ -969,7 +870,7 @@ class TestCompactIdleSession:
|
|||||||
|
|
||||||
reloaded = sessions.get_or_create("cli:noncontiguous")
|
reloaded = sessions.get_or_create("cli:noncontiguous")
|
||||||
assert len(reloaded.messages) == 25
|
assert len(reloaded.messages) == 25
|
||||||
assert reloaded.last_consolidated == 25
|
assert reloaded.last_consolidated == 14
|
||||||
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
|
assert [m["content"] for m in reloaded.get_history(max_messages=25)] == [
|
||||||
"user-14",
|
"user-14",
|
||||||
"assistant-00",
|
"assistant-00",
|
||||||
@@ -1133,7 +1034,7 @@ class TestConsolidatorSessionRefresh:
|
|||||||
|
|
||||||
session_after = sessions.get_or_create("cli:test")
|
session_after = sessions.get_or_create("cli:test")
|
||||||
assert len(session_after.messages) == 40
|
assert len(session_after.messages) == 40
|
||||||
assert session_after.last_consolidated == 40
|
assert session_after.last_consolidated == 32
|
||||||
assert len(session_after.get_history(max_messages=40)) == 8
|
assert len(session_after.get_history(max_messages=40)) == 8
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,19 @@ class TestDiffCommits:
|
|||||||
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
|
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindCommit:
|
||||||
|
def test_finds_by_prefix(self, git_ready):
|
||||||
|
ws = git_ready._workspace
|
||||||
|
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
|
||||||
|
sha = git_ready.auto_commit("v2")
|
||||||
|
found = git_ready.find_commit(sha[:4])
|
||||||
|
assert found is not None
|
||||||
|
assert found.sha == sha
|
||||||
|
|
||||||
|
def test_returns_none_for_unknown(self, git_ready):
|
||||||
|
assert git_ready.find_commit("deadbeef") is None
|
||||||
|
|
||||||
|
|
||||||
class TestShowCommitDiff:
|
class TestShowCommitDiff:
|
||||||
def test_returns_commit_with_diff(self, git_ready):
|
def test_returns_commit_with_diff(self, git_ready):
|
||||||
ws = git_ready._workspace
|
ws = git_ready._workspace
|
||||||
|
|||||||
@@ -1074,8 +1074,9 @@ async def test_process_message_persists_media_paths_on_user_turn(tmp_path: Path)
|
|||||||
"""User turns that attach images must record the media paths alongside
|
"""User turns that attach images must record the media paths alongside
|
||||||
the text so the webui can rehydrate previews on session replay.
|
the text so the webui can rehydrate previews on session replay.
|
||||||
|
|
||||||
The WebUI transcript replay can use these paths to restore attachment
|
This is the producer half of the signed-media-URL round-trip: paths are
|
||||||
previews when it backfills from canonical session history.
|
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 = tmp_path / "uuid-1.png"
|
||||||
img_a.write_bytes(_PNG_1X1)
|
img_a.write_bytes(_PNG_1X1)
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.bus.events import (
|
|
||||||
INBOUND_META_RUNTIME_CONTROL,
|
|
||||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
InboundMessage,
|
|
||||||
)
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
|
||||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
|
||||||
|
|
||||||
|
|
||||||
def _message(key: str, content: str) -> InboundMessage:
|
|
||||||
return InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="user",
|
|
||||||
chat_id=key.removeprefix("websocket:"),
|
|
||||||
content=content,
|
|
||||||
session_key_override=key,
|
|
||||||
require_existing_session=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
provider.generation = GenerationSettings()
|
|
||||||
provider.chat_with_retry = AsyncMock(
|
|
||||||
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
|
|
||||||
)
|
|
||||||
return AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
cron_service=MagicMock(),
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
|
|
||||||
loop = _loop(tmp_path, ["first answer", "second answer"])
|
|
||||||
loop.context.memory.write_memory("private durable memory")
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
|
||||||
key = "websocket:transient-test"
|
|
||||||
loop.sessions.get_or_create_transient(
|
|
||||||
key,
|
|
||||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
|
||||||
)
|
|
||||||
|
|
||||||
await loop._process_message(_message(key, "first question"))
|
|
||||||
await loop._process_message(_message(key, "second question"))
|
|
||||||
|
|
||||||
calls = loop.provider.chat_with_retry.await_args_list
|
|
||||||
assert "private durable memory" not in str(calls[0].kwargs["messages"])
|
|
||||||
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
|
|
||||||
assert "read_session" in tool_names
|
|
||||||
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
|
|
||||||
assert "first answer" in str(calls[1].kwargs["messages"])
|
|
||||||
session = loop.sessions.get_cached(key)
|
|
||||||
assert session is not None
|
|
||||||
assert [message["role"] for message in session.messages] == [
|
|
||||||
"user",
|
|
||||||
"assistant",
|
|
||||||
"user",
|
|
||||||
"assistant",
|
|
||||||
]
|
|
||||||
assert loop.sessions.read_session_file(key) is None
|
|
||||||
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
|
|
||||||
loop = _loop(tmp_path, ["private answer"], unified_session=True)
|
|
||||||
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
|
|
||||||
durable.add_message("user", "durable question")
|
|
||||||
loop.sessions.save(durable)
|
|
||||||
key = "websocket:transient-unified"
|
|
||||||
transient = loop.sessions.get_or_create_transient(key)
|
|
||||||
|
|
||||||
await loop._dispatch(_message(key, "private question"))
|
|
||||||
|
|
||||||
assert [message["content"] for message in transient.messages] == [
|
|
||||||
"private question",
|
|
||||||
"private answer",
|
|
||||||
]
|
|
||||||
assert [message["content"] for message in durable.messages] == ["durable question"]
|
|
||||||
assert loop.sessions.read_session_file(key) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
|
|
||||||
loop = _loop(tmp_path, [])
|
|
||||||
key = "websocket:transient-stale"
|
|
||||||
loop.sessions.get_or_create_transient(key)
|
|
||||||
loop.sessions.invalidate(key)
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="required session is not active"):
|
|
||||||
await loop._process_message(_message(key, "stale private message"))
|
|
||||||
|
|
||||||
loop.provider.chat_with_retry.assert_not_awaited()
|
|
||||||
assert loop.sessions.read_session_file(key) is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
|
|
||||||
provider_started = asyncio.Event()
|
|
||||||
|
|
||||||
async def block_provider(**_kwargs: object) -> LLMResponse:
|
|
||||||
provider_started.set()
|
|
||||||
await asyncio.Event().wait()
|
|
||||||
raise AssertionError("provider blocker unexpectedly released")
|
|
||||||
|
|
||||||
loop = _loop(tmp_path, [])
|
|
||||||
|
|
||||||
async def wait_for_discard(key: str) -> None:
|
|
||||||
while loop.sessions.get_cached(key) is not None or key in loop._discarding_sessions:
|
|
||||||
await asyncio.sleep(0)
|
|
||||||
|
|
||||||
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
|
||||||
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
|
|
||||||
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
|
|
||||||
terminate_exec_sessions = AsyncMock(return_value=1)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
loop._exec_session_manager,
|
|
||||||
"terminate_by_owner",
|
|
||||||
terminate_exec_sessions,
|
|
||||||
)
|
|
||||||
key = "websocket:transient-cancelled"
|
|
||||||
loop.sessions.get_or_create_transient(
|
|
||||||
key,
|
|
||||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
|
||||||
)
|
|
||||||
run_task = asyncio.create_task(loop.run())
|
|
||||||
await loop.bus.publish_inbound(_message(key, "private"))
|
|
||||||
await asyncio.wait_for(provider_started.wait(), timeout=2)
|
|
||||||
active_task = next(iter(loop._active_tasks[key]))
|
|
||||||
|
|
||||||
await loop.bus.publish_inbound(
|
|
||||||
InboundMessage(
|
|
||||||
channel="websocket",
|
|
||||||
sender_id="webui",
|
|
||||||
chat_id="transient-cancelled",
|
|
||||||
content="",
|
|
||||||
metadata={
|
|
||||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
|
||||||
},
|
|
||||||
session_key_override=key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
with pytest.raises(asyncio.CancelledError):
|
|
||||||
await asyncio.wait_for(active_task, timeout=2)
|
|
||||||
await asyncio.wait_for(wait_for_discard(key), timeout=2)
|
|
||||||
assert loop.sessions.get_cached(key) is None
|
|
||||||
terminate_exec_sessions.assert_awaited_once_with(key)
|
|
||||||
|
|
||||||
loop.stop()
|
|
||||||
await loop.bus.publish_inbound(_message(key, "wake"))
|
|
||||||
await asyncio.wait_for(run_task, timeout=2)
|
|
||||||
@@ -1092,23 +1092,6 @@ class TestMainMenuUpdate:
|
|||||||
assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}"
|
assert config.providers.openai.api_key == "${UNRELATED_MISSING_KEY}"
|
||||||
assert config.providers.openai_codex.proxy == "${CODEX_PROXY}"
|
assert config.providers.openai_codex.proxy == "${CODEX_PROXY}"
|
||||||
|
|
||||||
def test_quick_start_openai_codex_reports_incomplete_installation(self, monkeypatch):
|
|
||||||
import oauth_cli_kit
|
|
||||||
|
|
||||||
messages: list[str] = []
|
|
||||||
monkeypatch.delattr(oauth_cli_kit, "get_token")
|
|
||||||
monkeypatch.setattr(
|
|
||||||
onboard_wizard.console,
|
|
||||||
"print",
|
|
||||||
lambda message, *args, **kwargs: messages.append(str(message)),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert onboard_wizard._quick_start_oauth_login(Config(), "openai_codex") is False
|
|
||||||
assert messages == [
|
|
||||||
"[red]This nanobot installation is missing the required oauth-cli-kit package. "
|
|
||||||
"Reinstall or upgrade nanobot-ai using the same installation method.[/red]"
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token(
|
def test_quick_start_openai_codex_runs_interactive_login_for_bad_cached_token(
|
||||||
self, monkeypatch
|
self, monkeypatch
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from agent.runner_helpers import make_run_spec
|
from agent.runner_helpers import make_run_spec
|
||||||
from nanobot.agent.automation_turns import publish_next_deferred_turn
|
|
||||||
from nanobot.config.schema import AgentDefaults
|
from nanobot.config.schema import AgentDefaults
|
||||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
@@ -1048,11 +1047,7 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
|
|||||||
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
assert loop._cron_turns.deferred_queues[session_key] == [msg]
|
||||||
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
|
||||||
|
|
||||||
await publish_next_deferred_turn(
|
await loop._cron_turns.publish_next_deferred(session_key)
|
||||||
deferred_queues=loop._cron_turns.deferred_queues,
|
|
||||||
publish_inbound=loop.bus.publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
)
|
|
||||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||||
assert queued is msg
|
assert queued is msg
|
||||||
assert session_key not in loop._cron_turns.deferred_queues
|
assert session_key not in loop._cron_turns.deferred_queues
|
||||||
@@ -1102,11 +1097,7 @@ async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
|
|||||||
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
|
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
|
||||||
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
|
||||||
|
|
||||||
assert await publish_next_deferred_turn(
|
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
|
||||||
deferred_queues=loop._local_trigger_turns.deferred_queues,
|
|
||||||
publish_inbound=loop.bus.publish_inbound,
|
|
||||||
session_key=session_key,
|
|
||||||
) is True
|
|
||||||
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
|
||||||
assert queued is msg
|
assert queued is msg
|
||||||
assert session_key not in loop._local_trigger_turns.deferred_queues
|
assert session_key not in loop._local_trigger_turns.deferred_queues
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytest
|
|||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
from nanobot.agent.tools.runtime_control import AgentRuntimeControl
|
|
||||||
from nanobot.agent.tools.self import MyTool
|
from nanobot.agent.tools.self import MyTool
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ModelPresetConfig
|
from nanobot.config.schema import ModelPresetConfig
|
||||||
@@ -35,13 +34,6 @@ def _make_loop(tmp_path, presets=None, active_preset=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
|
||||||
return MyTool(
|
|
||||||
runtime_control=AgentRuntimeControl(loop),
|
|
||||||
modify_allowed=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
def test_model_preset_getter_none_when_not_set(tmp_path) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
@@ -248,7 +240,7 @@ def test_self_tool_inspect_shows_model_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
output = tool._inspect_all()
|
output = tool._inspect_all()
|
||||||
assert "model_preset: 'fast'" in output
|
assert "model_preset: 'fast'" in output
|
||||||
|
|
||||||
@@ -258,7 +250,7 @@ def test_self_tool_set_model_preset_via_modify(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
result = tool._modify("model_preset", "fast")
|
result = tool._modify("model_preset", "fast")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset == "fast"
|
assert loop.model_preset == "fast"
|
||||||
@@ -271,7 +263,7 @@ def test_self_tool_set_model_preset_switches_back_to_default(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1", context_window_tokens=32_768),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "default")
|
result = tool._modify("model_preset", "default")
|
||||||
|
|
||||||
@@ -288,7 +280,7 @@ def test_self_tool_set_model_preset_unknown_lists_available(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
result = tool._modify("model_preset", "missing")
|
result = tool._modify("model_preset", "missing")
|
||||||
|
|
||||||
@@ -303,7 +295,7 @@ def test_self_tool_sets_model_preset_for_current_session(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets)
|
loop = _make_loop(tmp_path, presets=presets)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -326,7 +318,7 @@ def test_self_tool_reports_session_preset_provider_configuration_error(tmp_path)
|
|||||||
loop.set_session_model_preset = MagicMock(
|
loop.set_session_model_preset = MagicMock(
|
||||||
side_effect=ValueError("No API key configured for provider 'openai'.")
|
side_effect=ValueError("No API key configured for provider 'openai'.")
|
||||||
)
|
)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
channel="cli",
|
channel="cli",
|
||||||
@@ -351,7 +343,7 @@ def test_self_tool_rejects_instance_runtime_changes_in_session(
|
|||||||
value: object,
|
value: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_loop(tmp_path)
|
loop = _make_loop(tmp_path)
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
session = loop.sessions.get_or_create("cli:one")
|
session = loop.sessions.get_or_create("cli:one")
|
||||||
|
|
||||||
with request_context(RequestContext(
|
with request_context(RequestContext(
|
||||||
@@ -374,7 +366,7 @@ def test_self_tool_set_model_clears_active_preset(tmp_path) -> None:
|
|||||||
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
"fast": ModelPresetConfig(model="openai/gpt-4.1"),
|
||||||
}
|
}
|
||||||
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
loop = _make_loop(tmp_path, presets=presets, active_preset="fast")
|
||||||
tool = _my_tool(loop)
|
tool = MyTool(runtime_state=loop, modify_allowed=True)
|
||||||
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
result = tool._modify("model", "anthropic/claude-opus-4-5")
|
||||||
assert "Error" not in result
|
assert "Error" not in result
|
||||||
assert loop.model_preset is None
|
assert loop.model_preset is None
|
||||||
|
|||||||
@@ -208,71 +208,6 @@ def test_orphan_trim_with_last_consolidated():
|
|||||||
assert all(m.get("role") != "tool" or m["tool_call_id"].startswith("new_") for m in history)
|
assert all(m.get("role") != "tool" or m["tool_call_id"].startswith("new_") for m in history)
|
||||||
|
|
||||||
|
|
||||||
def test_get_history_replays_recent_messages_after_full_archive():
|
|
||||||
session = Session(key="test:fully-archived")
|
|
||||||
for i in range(10):
|
|
||||||
session.messages.append({"role": "user", "content": f"u{i}"})
|
|
||||||
session.messages.append({"role": "assistant", "content": f"a{i}"})
|
|
||||||
session.last_consolidated = len(session.messages)
|
|
||||||
|
|
||||||
history = session.get_history(max_messages=100)
|
|
||||||
|
|
||||||
assert [message["content"] for message in history] == [
|
|
||||||
"u6",
|
|
||||||
"a6",
|
|
||||||
"u7",
|
|
||||||
"a7",
|
|
||||||
"u8",
|
|
||||||
"a8",
|
|
||||||
"u9",
|
|
||||||
"a9",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_history_extends_compacted_replay_to_preceding_user():
|
|
||||||
session = Session(key="test:compacted-tool-turn")
|
|
||||||
session.messages.extend(
|
|
||||||
[
|
|
||||||
{"role": "user", "content": "old"},
|
|
||||||
{"role": "assistant", "content": "old answer"},
|
|
||||||
{"role": "user", "content": "run tools"},
|
|
||||||
*_tool_turn("keep", 0),
|
|
||||||
*_tool_turn("keep", 1),
|
|
||||||
*_tool_turn("keep", 2),
|
|
||||||
{"role": "assistant", "content": "done"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
session.last_consolidated = len(session.messages)
|
|
||||||
|
|
||||||
history = session.get_history(max_messages=100)
|
|
||||||
|
|
||||||
assert history[0]["content"] == "run tools"
|
|
||||||
assert history[-1]["content"] == "done"
|
|
||||||
_assert_no_orphans(history)
|
|
||||||
|
|
||||||
|
|
||||||
def test_compacted_tool_turn_can_extend_past_message_cap():
|
|
||||||
session = Session(key="test:long-compacted-tool-turn")
|
|
||||||
session.messages.extend(
|
|
||||||
[
|
|
||||||
{"role": "user", "content": "old"},
|
|
||||||
{"role": "assistant", "content": "old answer"},
|
|
||||||
{"role": "user", "content": "run many tools"},
|
|
||||||
]
|
|
||||||
)
|
|
||||||
for i in range(50):
|
|
||||||
session.messages.extend(_tool_turn("keep", i))
|
|
||||||
session.messages.append({"role": "assistant", "content": "done"})
|
|
||||||
session.last_consolidated = len(session.messages)
|
|
||||||
|
|
||||||
history = session.get_history(max_messages=120)
|
|
||||||
|
|
||||||
assert len(history) > 120
|
|
||||||
assert history[0]["content"] == "run many tools"
|
|
||||||
assert history[-1]["content"] == "done"
|
|
||||||
_assert_no_orphans(history)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Edge: no tool messages at all ---
|
# --- Edge: no tool messages at all ---
|
||||||
|
|
||||||
def test_no_tool_messages_unchanged():
|
def test_no_tool_messages_unchanged():
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
from nanobot.session.manager import Session
|
|
||||||
|
|
||||||
|
|
||||||
def _assert_no_orphans(history: list[dict]) -> None:
|
|
||||||
declared = {
|
|
||||||
tc["id"]
|
|
||||||
for m in history
|
|
||||||
if m.get("role") == "assistant"
|
|
||||||
for tc in (m.get("tool_calls") or [])
|
|
||||||
}
|
|
||||||
orphans = [
|
|
||||||
m.get("tool_call_id")
|
|
||||||
for m in history
|
|
||||||
if m.get("role") == "tool" and m.get("tool_call_id") not in declared
|
|
||||||
]
|
|
||||||
assert orphans == [], f"orphan tool_call_ids: {orphans}"
|
|
||||||
|
|
||||||
|
|
||||||
def _delivery(content: str) -> dict:
|
|
||||||
return {"role": "assistant", "content": content, "_channel_delivery": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _tool_turn(prefix: str, idx: int) -> list[dict]:
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"role": "assistant",
|
|
||||||
"content": None,
|
|
||||||
"tool_calls": [
|
|
||||||
{
|
|
||||||
"id": f"{prefix}_{idx}_a",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "x", "arguments": "{}"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": f"{prefix}_{idx}_b",
|
|
||||||
"type": "function",
|
|
||||||
"function": {"name": "y", "arguments": "{}"},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_a", "name": "x", "content": "ok"},
|
|
||||||
{"role": "tool", "tool_call_id": f"{prefix}_{idx}_b", "name": "y", "content": "ok"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _contents(messages: list[dict]) -> list[str]:
|
|
||||||
return [m.get("content") for m in messages]
|
|
||||||
|
|
||||||
|
|
||||||
def _has_delivery(messages: list[dict]) -> bool:
|
|
||||||
return any(m.get("_channel_delivery") for m in messages)
|
|
||||||
|
|
||||||
|
|
||||||
# --- Hard-cap trimming must preserve a proactive delivery the user replied to ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_hard_cap_keeps_delivery_before_user():
|
|
||||||
session = Session(key="test:cap-delivery")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("Remember to drink water"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "great"})
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3)
|
|
||||||
|
|
||||||
assert _has_delivery(session.messages), "delivery dropped by hard-cap trim"
|
|
||||||
assert _contents(session.messages) == [
|
|
||||||
"Remember to drink water",
|
|
||||||
"ok",
|
|
||||||
"great",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_hard_cap_matches_get_history_boundary():
|
|
||||||
"""The trimmed suffix must start on the same message as get_history()."""
|
|
||||||
session = Session(key="test:cap-boundary")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
|
||||||
session.messages.append({"role": "user", "content": "show them"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "done"})
|
|
||||||
|
|
||||||
expected = session.get_history(max_messages=3)
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3)
|
|
||||||
|
|
||||||
assert _contents(session.messages) == _contents(expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_extend_to_user_keeps_delivery_before_recovered_user():
|
|
||||||
session = Session(key="test:extend-delivery")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "work"})
|
|
||||||
session.messages.append(_delivery("Reminder: deploy at 17:00"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a1"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a2"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a3"})
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
|
||||||
|
|
||||||
assert _has_delivery(session.messages), "delivery dropped by extend_to_user trim"
|
|
||||||
assert session.messages[0]["content"] == "Reminder: deploy at 17:00"
|
|
||||||
assert session.messages[-1]["content"] == "a3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_extend_to_user_matches_get_history_boundary():
|
|
||||||
session = Session(key="test:extend-boundary")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "work"})
|
|
||||||
session.messages.append(_delivery("Reminder: review the draft"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a1"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a2"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a3"})
|
|
||||||
|
|
||||||
expected = session.get_history(max_messages=3, extend_to_user=True)
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
|
||||||
|
|
||||||
assert _contents(session.messages) == _contents(expected)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_extend_to_user_does_not_extend_delivery_only_tail():
|
|
||||||
session = Session(key="test:extend-no-user")
|
|
||||||
for i in range(4):
|
|
||||||
session.messages.append(_delivery(f"notification {i}"))
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3, extend_to_user=True)
|
|
||||||
|
|
||||||
assert _contents(session.messages) == [
|
|
||||||
"notification 1",
|
|
||||||
"notification 2",
|
|
||||||
"notification 3",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Only the immediately-preceding delivery is part of the anchor ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_keeps_only_immediate_delivery():
|
|
||||||
session = Session(key="test:multi-delivery")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("old scheduled note"))
|
|
||||||
session.messages.append(_delivery("new scheduled note"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "great"})
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(3)
|
|
||||||
|
|
||||||
kept = _contents(session.messages)
|
|
||||||
assert kept == ["new scheduled note", "ok", "great"], kept
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_drops_delivery_not_adjacent_to_anchor_user():
|
|
||||||
"""A delivery that does not immediately precede the retained user turn is
|
|
||||||
not part of the anchor and should not be force-retained."""
|
|
||||||
session = Session(key="test:nonadjacent")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("unrelated scheduled note"))
|
|
||||||
session.messages.append({"role": "assistant", "content": "reply"})
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "great"})
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(2)
|
|
||||||
|
|
||||||
assert not _has_delivery(session.messages)
|
|
||||||
assert _contents(session.messages) == ["ok", "great"]
|
|
||||||
|
|
||||||
|
|
||||||
# --- Delivery preservation through the production entry points ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_file_cap_keeps_delivery_in_session():
|
|
||||||
session = Session(key="test:cap-delivery")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("Remember to drink water"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "great"})
|
|
||||||
|
|
||||||
archived: list[list[dict]] = []
|
|
||||||
session.enforce_file_cap(on_archive=archived.append, limit=3)
|
|
||||||
|
|
||||||
archived_flat = [m for chunk in archived for m in chunk]
|
|
||||||
assert _has_delivery(session.messages)
|
|
||||||
assert not any(m.get("_channel_delivery") for m in archived_flat)
|
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_file_cap_archives_only_prefix():
|
|
||||||
session = Session(key="test:cap-prefix")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "first reply"})
|
|
||||||
session.messages.append(_delivery("Remember to drink water"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "great"})
|
|
||||||
|
|
||||||
archived: list[list[dict]] = []
|
|
||||||
session.enforce_file_cap(on_archive=archived.append, limit=3)
|
|
||||||
|
|
||||||
archived_flat = [m for chunk in archived for m in chunk]
|
|
||||||
assert _has_delivery(session.messages)
|
|
||||||
assert _contents(archived_flat) == ["setup", "first reply"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_compact_probe_keeps_delivery_in_visible_suffix():
|
|
||||||
"""compact_idle_session() trims a probe copy with extend_to_user=True; the
|
|
||||||
visible suffix it keeps must still contain the delivery message."""
|
|
||||||
tail = [
|
|
||||||
{"role": "user", "content": "setup"},
|
|
||||||
{"role": "assistant", "content": "work"},
|
|
||||||
_delivery("Reminder: deploy at 17:00"),
|
|
||||||
{"role": "user", "content": "ok"},
|
|
||||||
{"role": "assistant", "content": "a1"},
|
|
||||||
{"role": "assistant", "content": "a2"},
|
|
||||||
{"role": "assistant", "content": "a3"},
|
|
||||||
]
|
|
||||||
probe = Session(key="test:probe", messages=tail, last_consolidated=0)
|
|
||||||
|
|
||||||
probe.retain_recent_legal_suffix(3, extend_to_user=True)
|
|
||||||
|
|
||||||
assert _has_delivery(probe.messages)
|
|
||||||
assert probe.messages[0]["content"] == "Reminder: deploy at 17:00"
|
|
||||||
|
|
||||||
|
|
||||||
# --- Trimming must stay coherent with the rest of replay ---
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_then_replay_keeps_delivery_and_no_orphans():
|
|
||||||
session = Session(key="test:replay-after-trim")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append(_delivery("You have 3 pending tasks"))
|
|
||||||
session.messages.append({"role": "user", "content": "show them"})
|
|
||||||
session.messages.extend(_tool_turn("cur", 0))
|
|
||||||
session.messages.append({"role": "assistant", "content": "done"})
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(6)
|
|
||||||
|
|
||||||
assert _has_delivery(session.messages)
|
|
||||||
history = session.get_history(max_messages=500)
|
|
||||||
_assert_no_orphans(history)
|
|
||||||
assert any(m.get("content") == "You have 3 pending tasks" for m in history)
|
|
||||||
|
|
||||||
|
|
||||||
def test_retain_keeps_delivery_when_user_inside_window():
|
|
||||||
"""When the capped window already contains a user, its immediately
|
|
||||||
preceding delivery must stay attached to it."""
|
|
||||||
session = Session(key="test:window-user")
|
|
||||||
session.messages.append({"role": "user", "content": "setup"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a0"})
|
|
||||||
session.messages.append(_delivery("Reminder"))
|
|
||||||
session.messages.append({"role": "user", "content": "ok"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a1"})
|
|
||||||
session.messages.append({"role": "assistant", "content": "a2"})
|
|
||||||
|
|
||||||
expected = session.get_history(max_messages=4)
|
|
||||||
|
|
||||||
session.retain_recent_legal_suffix(4)
|
|
||||||
|
|
||||||
assert _has_delivery(session.messages)
|
|
||||||
assert _contents(session.messages) == _contents(expected)
|
|
||||||
@@ -84,6 +84,10 @@ class TestToolHintKnownTools:
|
|||||||
assert '"C:/Program Files/Git/project"' not in result
|
assert '"C:/Program Files/Git/project"' not in result
|
||||||
assert '"' in result
|
assert '"' in result
|
||||||
|
|
||||||
|
def test_exec_short_command_unchanged(self):
|
||||||
|
result = _hint([_tc("exec", {"command": "npm install typescript"})])
|
||||||
|
assert result == "$ npm install typescript"
|
||||||
|
|
||||||
def test_exec_chained_commands_truncated_not_mid_path(self):
|
def test_exec_chained_commands_truncated_not_mid_path(self):
|
||||||
"""Long chained commands should truncate preserving abbreviated paths."""
|
"""Long chained commands should truncate preserving abbreviated paths."""
|
||||||
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
cmd = "cd D:\\Documents\\GitHub\\project && npm run build && npm test"
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
"""Contract and security regressions for the MyTool runtime boundary."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.agent.tools.runtime_control import (
|
|
||||||
RUNTIME_COMMAND_KEYS,
|
|
||||||
RUNTIME_SNAPSHOT_KEYS,
|
|
||||||
AgentRuntimeControl,
|
|
||||||
RuntimeControl,
|
|
||||||
)
|
|
||||||
from nanobot.agent.tools.self import MyTool, MyToolConfig
|
|
||||||
from nanobot.bus.queue import MessageBus
|
|
||||||
from nanobot.config.schema import ToolsConfig
|
|
||||||
|
|
||||||
|
|
||||||
def _make_loop(tmp_path: Path, *, allow_set: bool = False) -> AgentLoop:
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_default_model.return_value = "test-model"
|
|
||||||
tools_config = ToolsConfig(my=MyToolConfig(allow_set=allow_set))
|
|
||||||
return AgentLoop(
|
|
||||||
bus=MessageBus(),
|
|
||||||
provider=provider,
|
|
||||||
workspace=tmp_path,
|
|
||||||
model="test-model",
|
|
||||||
tools_config=tools_config,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _my_tool(loop: AgentLoop) -> MyTool:
|
|
||||||
tool = loop.tools.get("my")
|
|
||||||
assert isinstance(tool, MyTool)
|
|
||||||
return tool
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_loop_assembles_my_tool_with_runtime_control(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
tool = _my_tool(loop)
|
|
||||||
|
|
||||||
assert isinstance(tool._runtime_control, RuntimeControl)
|
|
||||||
assert isinstance(tool._runtime_control, AgentRuntimeControl)
|
|
||||||
assert tool._runtime_control is not loop
|
|
||||||
assert not hasattr(tool, "_runtime_state")
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_snapshot_has_exact_allowlist_and_redacts_secrets(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
loop.web_config.search.api_key = "search-secret"
|
|
||||||
loop.web_config.proxy = "http://proxy-user:proxy-secret@proxy.example"
|
|
||||||
loop.unlisted_secret = "loop-secret"
|
|
||||||
|
|
||||||
snapshot = _my_tool(loop)._runtime_control.snapshot()
|
|
||||||
values = snapshot.as_mapping()
|
|
||||||
|
|
||||||
assert frozenset(values) == RUNTIME_SNAPSHOT_KEYS
|
|
||||||
assert RUNTIME_COMMAND_KEYS == frozenset({
|
|
||||||
"model",
|
|
||||||
"model_preset",
|
|
||||||
"max_iterations",
|
|
||||||
"context_window_tokens",
|
|
||||||
"provider_retry_mode",
|
|
||||||
"max_tool_result_chars",
|
|
||||||
"workspace",
|
|
||||||
})
|
|
||||||
assert "provider" not in values
|
|
||||||
assert "sessions" not in values
|
|
||||||
assert "restrict_to_workspace" not in values
|
|
||||||
assert "unlisted_secret" not in values
|
|
||||||
rendered = repr(values)
|
|
||||||
assert "search-secret" not in rendered
|
|
||||||
assert "proxy-secret" not in rendered
|
|
||||||
assert "loop-secret" not in rendered
|
|
||||||
assert snapshot.web_config["proxy"] == "<configured>"
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_snapshot_is_detached_from_mutable_config(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
control = _my_tool(loop)._runtime_control
|
|
||||||
snapshot = control.snapshot()
|
|
||||||
search = snapshot.web_config["search"]
|
|
||||||
assert isinstance(search, dict)
|
|
||||||
|
|
||||||
search["provider"] = "mutated"
|
|
||||||
snapshot.exec_config["allow_patterns"] = ["mutated"]
|
|
||||||
snapshot.tool_names.append("mutated")
|
|
||||||
|
|
||||||
refreshed = control.snapshot()
|
|
||||||
refreshed_search = refreshed.web_config["search"]
|
|
||||||
assert isinstance(refreshed_search, dict)
|
|
||||||
assert refreshed_search["provider"] == loop.web_config.search.provider
|
|
||||||
assert refreshed.exec_config["allow_patterns"] == loop.exec_config.allow_patterns
|
|
||||||
assert "mutated" not in refreshed.tool_names
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_unlisted_loop_attributes_cannot_be_read_or_modified(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path, allow_set=True)
|
|
||||||
loop.unlisted_control_plane = "internal-secret"
|
|
||||||
original_workspace_root = loop.workspace_scopes.default_workspace
|
|
||||||
tool = _my_tool(loop)
|
|
||||||
|
|
||||||
inspected = await tool.execute(action="check", key="unlisted_control_plane")
|
|
||||||
modified = await tool.execute(
|
|
||||||
action="set",
|
|
||||||
key="unlisted_control_plane",
|
|
||||||
value="scratch-value",
|
|
||||||
)
|
|
||||||
nested = await tool.execute(
|
|
||||||
action="set",
|
|
||||||
key="workspace_scopes.default_workspace",
|
|
||||||
value="elsewhere",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert "internal-secret" not in inspected
|
|
||||||
assert "not found" in inspected
|
|
||||||
assert modified == "Set scratchpad.unlisted_control_plane = 'scratch-value'"
|
|
||||||
assert loop.unlisted_control_plane == "internal-secret"
|
|
||||||
assert "Error" in nested
|
|
||||||
assert loop.workspace_scopes.default_workspace == original_workspace_root
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_default_allow_set_and_public_parameter_schema_are_unchanged(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
loop = _make_loop(tmp_path)
|
|
||||||
tool = _my_tool(loop)
|
|
||||||
|
|
||||||
assert ToolsConfig().my.allow_set is False
|
|
||||||
assert tool.parameters == {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"action": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["check", "set"],
|
|
||||||
"description": "Action to perform",
|
|
||||||
},
|
|
||||||
"key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
"Dot-path for check/set. Examples: 'max_iterations', 'workspace', "
|
|
||||||
"'provider_retry_mode'. Use 'request.channel', 'request.chat_id', or "
|
|
||||||
"'request.sender_id' for current routing metadata. Use 'model_preset' "
|
|
||||||
"to switch named model presets. For check without key, shows all "
|
|
||||||
"config values."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"value": {
|
|
||||||
"description": (
|
|
||||||
"New value (for set). Type must match target (int for "
|
|
||||||
"max_iterations/context_window_tokens, str for model/model_preset)."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["action"],
|
|
||||||
}
|
|
||||||
assert "READ-ONLY MODE" in tool.description
|
|
||||||
result = await tool.execute(action="set", key="max_iterations", value=80)
|
|
||||||
assert result == "Error: set is disabled (tools.my.allow_set is false)"
|
|
||||||
assert loop.max_iterations != 80
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_allowlisted_commands_preserve_runtime_side_effects(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path, allow_set=True)
|
|
||||||
tool = _my_tool(loop)
|
|
||||||
|
|
||||||
max_iterations = await tool.execute(
|
|
||||||
action="set",
|
|
||||||
key="max_iterations",
|
|
||||||
value=80,
|
|
||||||
)
|
|
||||||
retry_mode = await tool.execute(
|
|
||||||
action="set",
|
|
||||||
key="provider_retry_mode",
|
|
||||||
value="persistent",
|
|
||||||
)
|
|
||||||
scratchpad = await tool.execute(
|
|
||||||
action="set",
|
|
||||||
key="preference",
|
|
||||||
value={"concise": True},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert max_iterations == "Set max_iterations = 80 (was 200)"
|
|
||||||
assert retry_mode == "Set provider_retry_mode = 'persistent' (was 'standard')"
|
|
||||||
assert scratchpad == "Set scratchpad.preference = {'concise': True}"
|
|
||||||
assert loop.max_iterations == 80
|
|
||||||
assert loop.subagents.max_iterations == 80
|
|
||||||
assert loop.provider_retry_mode == "persistent"
|
|
||||||
assert tool._runtime_control.snapshot().scratchpad == {
|
|
||||||
"preference": {"concise": True},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_registry_exposes_unchanged_my_tool_actions(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path, allow_set=True)
|
|
||||||
|
|
||||||
checked = await loop.tools.execute("my", {"action": "check", "key": "model"})
|
|
||||||
changed = await loop.tools.execute(
|
|
||||||
"my",
|
|
||||||
{"action": "set", "key": "max_iterations", "value": 80},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert checked == "model: 'test-model'"
|
|
||||||
assert changed == "Set max_iterations = 80 (was 200)"
|
|
||||||
assert loop.max_iterations == 80
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_workspace_display_command_cannot_change_path_enforcement(tmp_path: Path) -> None:
|
|
||||||
loop = _make_loop(tmp_path, allow_set=True)
|
|
||||||
tool = _my_tool(loop)
|
|
||||||
|
|
||||||
result = await tool.execute(action="set", key="workspace", value="elsewhere")
|
|
||||||
|
|
||||||
assert "Set workspace" in result
|
|
||||||
assert tool._runtime_control.snapshot().workspace == "elsewhere"
|
|
||||||
assert loop.workspace == tmp_path
|
|
||||||
assert loop.workspace_scopes.default_workspace == tmp_path
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user