mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-09 13:58:36 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d411d9d3 |
@@ -33,7 +33,6 @@ Pick the row that matches what you want to accomplish next:
|
|||||||
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
| Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
|
||||||
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
| Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
|
||||||
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
| Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
|
||||||
| Install and govern an extension | [Extensions](./extensions.md) |
|
|
||||||
| Generate images | [Image Generation](./image-generation.md) |
|
| Generate images | [Image Generation](./image-generation.md) |
|
||||||
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
| Schedule work or create a local trigger | [Automations](./automations.md) |
|
||||||
| Understand and manage long-term memory | [Memory](./memory.md) |
|
| Understand and manage long-term memory | [Memory](./memory.md) |
|
||||||
@@ -80,7 +79,6 @@ These pages explain implementation and extension points. You do not need them to
|
|||||||
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
|
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
|
||||||
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
|
||||||
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
|
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
|
||||||
| Publish an extension package | [Extension Authoring](./extension-authoring.md) |
|
|
||||||
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
| Build the WebUI source | [WebUI Development](../webui/README.md) |
|
||||||
|
|
||||||
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
|
If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ message. Copy the `nanobot trigger ...` command from the WebUI and replace
|
|||||||
Automation delivery is workspace-local. Scheduled jobs and local trigger
|
Automation delivery is workspace-local. Scheduled jobs and local trigger
|
||||||
deliveries use the same workspace as the gateway.
|
deliveries use the same workspace as the gateway.
|
||||||
|
|
||||||
|
WebUI automation replies are written to the linked topic even when no browser
|
||||||
|
is connected. When the WebUI is opened again, it replays the stored reply and
|
||||||
|
compares the topic's durable activity time with its persisted read position to
|
||||||
|
show **New activity**. A successful automation `lastStatus` means the agent turn
|
||||||
|
completed; it does not mean a browser had a live WebSocket connection or that
|
||||||
|
the user already read the reply.
|
||||||
|
|
||||||
Local trigger messages are written to a durable queue. If the gateway is not
|
Local trigger messages are written to a durable queue. If the gateway is not
|
||||||
running yet, the message waits in that workspace. If the linked topic is
|
running yet, the message waits in that workspace. If the linked topic is
|
||||||
already running a turn, the trigger waits until the session becomes idle instead
|
already running a turn, the trigger waits until the session becomes idle instead
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
|
||||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||||
| Manage extension packages | `nanobot extensions list` | Install, inspect, trust, enable, and remove native nanobot packages |
|
|
||||||
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
|
||||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||||
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
|
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
|
||||||
@@ -249,39 +248,6 @@ nanobot channels status
|
|||||||
|
|
||||||
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
|
||||||
|
|
||||||
## Extensions
|
|
||||||
|
|
||||||
Extension installation, trust, permission grants, and enablement are separate
|
|
||||||
operations:
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---|---|
|
|
||||||
| `nanobot extensions list` | Show installed packages and activation policy |
|
|
||||||
| `nanobot extensions inspect <id>` | Show identity, dependencies, requested permissions, and diagnostics |
|
|
||||||
| `nanobot extensions install <url> --kind git [--ref <ref>]` | Install from a Git branch, tag, or commit |
|
|
||||||
| `nanobot extensions install <path> --kind local` | Install from a local package directory |
|
|
||||||
| `nanobot extensions permissions <id> [permissions...]` | Replace the exact granted permission set; omit values to revoke all |
|
|
||||||
| `nanobot extensions trust <id>` | Approve executing the installed package |
|
|
||||||
| `nanobot extensions untrust <id>` | Revoke trust and stop activation |
|
|
||||||
| `nanobot extensions enable <id>` | Allow activation when every other gate passes |
|
|
||||||
| `nanobot extensions disable <id>` | Stop activation without uninstalling |
|
|
||||||
| `nanobot extensions uninstall <id>` | Remove the user-scope package after confirmation |
|
|
||||||
| `nanobot extensions uninstall <id> --yes` | Remove without an interactive confirmation |
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions install https://github.com/acme/nanobot-review.git
|
|
||||||
nanobot extensions inspect acme.review
|
|
||||||
nanobot extensions permissions acme.review workspace.read
|
|
||||||
nanobot extensions trust acme.review
|
|
||||||
nanobot extensions enable acme.review
|
|
||||||
```
|
|
||||||
|
|
||||||
Installed packages live under `~/.nanobot/extensions/`. They do not execute
|
|
||||||
until trusted. See [Extensions](./extensions.md) for the safety model and
|
|
||||||
[Extension Authoring](./extension-authoring.md) for the native package contract.
|
|
||||||
|
|
||||||
## Optional Features
|
## Optional Features
|
||||||
|
|
||||||
Use these commands when you want nanobot to add or remove a built-in capability
|
Use these commands when you want nanobot to add or remove a built-in capability
|
||||||
|
|||||||
+1
-28
@@ -27,7 +27,6 @@ the focused guides first and come back here for exact fields and defaults.
|
|||||||
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
|
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
|
||||||
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
|
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
|
||||||
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
|
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
|
||||||
| Install and govern extensions | [`extensions.md`](./extensions.md) |
|
|
||||||
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
|
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
|
||||||
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ the focused guides first and come back here for exact fields and defaults.
|
|||||||
| Configure web search and fetch | [Web Tools](#web-tools) |
|
| Configure web search and fetch | [Web Tools](#web-tools) |
|
||||||
| Enable image generation | [Image Generation](#image-generation) |
|
| Enable image generation | [Image Generation](#image-generation) |
|
||||||
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
|
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
|
||||||
| Enable or disable external extensions | [Extensions](#extensions) |
|
|
||||||
| Review shell, workspace, and SSRF controls | [Security](#security) |
|
| Review shell, workspace, and SSRF controls | [Security](#security) |
|
||||||
| Control access and pairing | [Pairing](#pairing) |
|
| Control access and pairing | [Pairing](#pairing) |
|
||||||
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
|
||||||
@@ -66,7 +64,6 @@ If the WebUI does not expose the option you need, start from the task below. Mos
|
|||||||
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
|
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
|
||||||
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
|
||||||
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
|
||||||
| Enable external extension packages | `extensions.enabled` | `nanobot extensions list`, then inspect the package | [Extensions](#extensions), [Extension guide](./extensions.md) |
|
|
||||||
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
|
||||||
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
| Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
|
||||||
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
|
||||||
@@ -2000,7 +1997,7 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
|
|||||||
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
|
||||||
| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. |
|
| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. |
|
||||||
| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. |
|
| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. |
|
||||||
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install optional support or extension packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin may install packages into this environment. |
|
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
|
||||||
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
|
||||||
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
|
||||||
|
|
||||||
@@ -2236,30 +2233,6 @@ When enabled, all incoming messages — regardless of which channel they arrive
|
|||||||
|
|
||||||
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
|
> This is designed for single-user, multi-device setups. It is **off by default** — existing users see zero behavior change.
|
||||||
|
|
||||||
## Extensions
|
|
||||||
|
|
||||||
Use the WebUI **Extensions** page or `nanobot extensions` commands for normal
|
|
||||||
installation and trust decisions. Extension support can be disabled globally:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"extensions": {
|
|
||||||
"enabled": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|---|---|---|
|
|
||||||
| `extensions.enabled` | `true` | Enable external extension discovery and activation |
|
|
||||||
|
|
||||||
Installed packages and their trust, permission, and activation state live
|
|
||||||
under `~/.nanobot/extensions/`. Managing an extension does not rewrite
|
|
||||||
`config.json`.
|
|
||||||
|
|
||||||
See [Extensions](./extensions.md) for the safe install flow and
|
|
||||||
[Extension Authoring](./extension-authoring.md) for the package contract.
|
|
||||||
|
|
||||||
## Disabled Skills
|
## Disabled Skills
|
||||||
|
|
||||||
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
|
nanobot ships with built-in skills, and your workspace can also define custom skills under `skills/`. If you want to hide specific skills from the agent, set `agents.defaults.disabledSkills` to a list of skill directory names:
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
# Extension Authoring
|
|
||||||
|
|
||||||
A native nanobot extension is a directory containing:
|
|
||||||
|
|
||||||
```text
|
|
||||||
nanobot-review/
|
|
||||||
├── nanobot.extension.json
|
|
||||||
└── extension.py
|
|
||||||
```
|
|
||||||
|
|
||||||
The manifest describes identity, activation prerequisites, and requested
|
|
||||||
permissions. The Python entry point performs the real registration. This keeps
|
|
||||||
one authoritative source for tool, command, and hook ownership.
|
|
||||||
|
|
||||||
## Manifest
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "acme.review",
|
|
||||||
"name": "Acme Review",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"entry": "extension:register",
|
|
||||||
"description": "Adds repository review tools.",
|
|
||||||
"apiVersion": 1,
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://github.com/acme/nanobot-review",
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"kind": "executable",
|
|
||||||
"name": "git"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
{
|
|
||||||
"name": "workspace.read",
|
|
||||||
"reason": "Read files selected for review."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Required fields are `id`, `name`, and `version`. `entry` defaults to
|
|
||||||
`"extension:register"` and `apiVersion` defaults to `1`.
|
|
||||||
|
|
||||||
IDs use lowercase letters, digits, dots, underscores, and hyphens. Entry points
|
|
||||||
use `module:function` syntax and must resolve inside the package.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
| Kind | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `python` | Installed Python distribution; `specifier` accepts a version constraint |
|
|
||||||
| `executable` | Command available on `PATH` |
|
|
||||||
| `environment` | Non-empty environment variable |
|
|
||||||
|
|
||||||
Set `"optional": true` when a missing dependency should not block activation.
|
|
||||||
|
|
||||||
### Permissions
|
|
||||||
|
|
||||||
Permissions are lowercase namespaced identifiers chosen by the package, such
|
|
||||||
as `workspace.read` or `network`. Give each permission a concrete reason.
|
|
||||||
Activation waits until every requested permission is granted.
|
|
||||||
|
|
||||||
The host currently uses permissions as explicit user consent. They do not
|
|
||||||
sandbox Python code, so do not describe a permission as stronger isolation
|
|
||||||
than it provides.
|
|
||||||
|
|
||||||
## Registration API
|
|
||||||
|
|
||||||
The entry point receives `PythonExtensionApi` and must return `None`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewTool(Tool):
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "review_repository"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self) -> str:
|
|
||||||
return "Review the current repository."
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self) -> dict[str, Any]:
|
|
||||||
return {"type": "object", "properties": {}}
|
|
||||||
|
|
||||||
async def execute(self, **kwargs: Any) -> str:
|
|
||||||
return "No findings."
|
|
||||||
|
|
||||||
|
|
||||||
def register(api) -> None:
|
|
||||||
api.register_tool(ReviewTool())
|
|
||||||
```
|
|
||||||
|
|
||||||
The API has three stable methods:
|
|
||||||
|
|
||||||
```python
|
|
||||||
api.register_tool(tool)
|
|
||||||
api.register_command("review", handler)
|
|
||||||
api.register_hook_factory(factory)
|
|
||||||
```
|
|
||||||
|
|
||||||
Command handlers use nanobot's `CommandContext` and return an
|
|
||||||
`OutboundMessage` or `None`. Hook factories receive `AgentTurnHookContext` and
|
|
||||||
return an `AgentHook` or `None`.
|
|
||||||
|
|
||||||
Do not modify `AgentLoop` or global registries directly. The API tags every
|
|
||||||
registration with the extension ID so reload, failure rollback, and uninstall
|
|
||||||
can remove exactly what the package owns.
|
|
||||||
|
|
||||||
## Collision and failure behavior
|
|
||||||
|
|
||||||
Tool and command names are unique across core and active extensions. If an
|
|
||||||
extension registers a duplicate name, activation fails for that extension and
|
|
||||||
all of its partial registrations are rolled back.
|
|
||||||
|
|
||||||
Missing dependencies are reported as diagnostics instead of crashing the
|
|
||||||
gateway.
|
|
||||||
|
|
||||||
## Develop locally
|
|
||||||
|
|
||||||
1. Create the manifest and entry module.
|
|
||||||
2. Install the directory with `--kind local`.
|
|
||||||
3. Inspect and grant its permissions.
|
|
||||||
4. Trust it.
|
|
||||||
5. Reinstall after editing so nanobot records a new integrity digest.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions install "$PWD" --kind local
|
|
||||||
nanobot extensions inspect acme.review
|
|
||||||
nanobot extensions permissions acme.review workspace.read
|
|
||||||
nanobot extensions trust acme.review
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep tests in the extension repository. At minimum, test registration,
|
|
||||||
duplicate-name failure, and behavior when each required dependency is missing.
|
|
||||||
|
|
||||||
## Distribution
|
|
||||||
|
|
||||||
Publish the directory in a Git repository. Users can pin a release tag or
|
|
||||||
commit with `--ref`. The repository root must contain
|
|
||||||
`nanobot.extension.json`; install scripts and generated compatibility manifests
|
|
||||||
are not part of the native contract.
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Extensions
|
|
||||||
|
|
||||||
Extensions add native tools, slash commands, or lifecycle hooks without
|
|
||||||
changing nanobot core. An extension is a Python package with one manifest and
|
|
||||||
one registration entry point.
|
|
||||||
|
|
||||||
Use an extension when a capability needs executable integration with nanobot.
|
|
||||||
Use a [skill](./skills.md) when instructions alone are enough, an App when the
|
|
||||||
agent should call an external CLI, and MCP when a service already exposes an
|
|
||||||
MCP server.
|
|
||||||
|
|
||||||
## Install
|
|
||||||
|
|
||||||
Install from a Git repository:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions install https://github.com/acme/nanobot-review.git
|
|
||||||
```
|
|
||||||
|
|
||||||
Install a local package while developing it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions install /absolute/path/to/nanobot-review --kind local
|
|
||||||
```
|
|
||||||
|
|
||||||
Git installs may select a branch, tag, or commit:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions install https://github.com/acme/nanobot-review.git \
|
|
||||||
--ref v1.2.0
|
|
||||||
```
|
|
||||||
|
|
||||||
The WebUI **Extensions** page exposes the same Git and local installation
|
|
||||||
flows. Local paths are accepted only from a browser running on the nanobot
|
|
||||||
host.
|
|
||||||
|
|
||||||
## Review before activation
|
|
||||||
|
|
||||||
New packages are installed enabled but untrusted. They cannot execute until
|
|
||||||
you review the manifest, grant every requested permission, and trust them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions inspect acme.review
|
|
||||||
nanobot extensions permissions acme.review workspace.read
|
|
||||||
nanobot extensions trust acme.review
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `list` to check the result:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions list
|
|
||||||
```
|
|
||||||
|
|
||||||
Disable, untrust, or remove a package at any time:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
nanobot extensions disable acme.review
|
|
||||||
nanobot extensions untrust acme.review
|
|
||||||
nanobot extensions uninstall acme.review
|
|
||||||
```
|
|
||||||
|
|
||||||
Changes made in the WebUI reload its gateway extension host immediately.
|
|
||||||
Changes made by the standalone CLI take effect the next time the gateway or
|
|
||||||
agent process starts. Failed registrations are rolled back and reported as
|
|
||||||
diagnostics.
|
|
||||||
|
|
||||||
## Safety model
|
|
||||||
|
|
||||||
Extensions are executable Python code. nanobot provides these controls:
|
|
||||||
|
|
||||||
- packages are copied into `~/.nanobot/extensions/` with an integrity digest;
|
|
||||||
- package symlinks and special files are rejected;
|
|
||||||
- installation, permission grants, trust, and activation are separate steps;
|
|
||||||
- changed package contents invalidate trust;
|
|
||||||
- registration is transactional, so a failed extension does not leave tools,
|
|
||||||
commands, or hooks behind;
|
|
||||||
- remote WebUI clients cannot grant trust or permissions.
|
|
||||||
|
|
||||||
Permission declarations are consent gates, not an operating-system sandbox.
|
|
||||||
Only install code you are willing to run with the same account as nanobot.
|
|
||||||
|
|
||||||
## Package compatibility
|
|
||||||
|
|
||||||
The core runtime intentionally executes only the native nanobot Python
|
|
||||||
contract. Pi and OpenClaw packages are not loaded directly. Compatibility
|
|
||||||
adapters can be distributed as separate nanobot extensions later without
|
|
||||||
adding JavaScript runtimes or package-market policy to the agent core.
|
|
||||||
|
|
||||||
See [Extension Authoring](./extension-authoring.md) to build a package.
|
|
||||||
+6
-1
@@ -186,7 +186,9 @@ Dream is configured under `agents.defaults.dream`:
|
|||||||
"defaults": {
|
"defaults": {
|
||||||
"dream": {
|
"dream": {
|
||||||
"intervalH": 2,
|
"intervalH": 2,
|
||||||
"modelOverride": null
|
"modelOverride": null,
|
||||||
|
"maxBatchSize": 20,
|
||||||
|
"maxIterations": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,12 +200,15 @@ Dream is configured under `agents.defaults.dream`:
|
|||||||
| `intervalH` | How often Dream runs, in hours |
|
| `intervalH` | How often Dream runs, in hours |
|
||||||
| `cron` | Cron expression override (takes precedence over `intervalH`) |
|
| `cron` | Cron expression override (takes precedence over `intervalH`) |
|
||||||
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
|
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* |
|
||||||
|
| `maxBatchSize` | *(Deprecated — not used)* |
|
||||||
|
| `maxIterations` | *(Deprecated — not used)* |
|
||||||
|
|
||||||
In practical terms:
|
In practical terms:
|
||||||
|
|
||||||
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
|
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
|
||||||
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
|
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
|
||||||
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
|
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent.
|
||||||
|
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
|
||||||
|
|
||||||
## In Practice
|
## In Practice
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -285,9 +285,9 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
|
|||||||
form.
|
form.
|
||||||
|
|
||||||
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
Remote WebUI clients with a valid token can view and use Apps. Actions that
|
||||||
install missing nanobot support packages or first-class extension packages are
|
install missing nanobot support packages, such as adding a channel dependency,
|
||||||
blocked by default. To let trusted remote administrators place packages into
|
are blocked by default. To let trusted remote administrators change the Python
|
||||||
this nanobot installation through the WebUI, opt in explicitly:
|
environment through the WebUI, opt in explicitly:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -298,15 +298,12 @@ this nanobot installation 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 the nanobot installation. A remotely installed extension
|
trusted to change the Python environment that nanobot runs in. If you publish
|
||||||
remains untrusted and inactive: trust, permission grants, activation, disabling,
|
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
|
||||||
and removal stay restricted to a browser on the nanobot host. If you publish the
|
as remote access and leave package installs disabled unless that is intentional.
|
||||||
WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it as
|
|
||||||
remote access and leave package 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`. Extension packages install from an explicit Git repository or
|
`PIP_INDEX_URL`.
|
||||||
local directory.
|
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ContextBuilder:
|
|||||||
|
|
||||||
def build_system_prompt(
|
def build_system_prompt(
|
||||||
self,
|
self,
|
||||||
*,
|
skill_names: list[str] | None = None,
|
||||||
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,
|
||||||
@@ -196,11 +196,14 @@ class ContextBuilder:
|
|||||||
self,
|
self,
|
||||||
history: list[dict[str, Any]],
|
history: list[dict[str, Any]],
|
||||||
current_message: str,
|
current_message: str,
|
||||||
*,
|
skill_names: list[str] | None = None,
|
||||||
media: list[str] | None = None,
|
media: list[str] | None = None,
|
||||||
channel: str | None = None,
|
channel: str | None = None,
|
||||||
|
chat_id: str | None = None,
|
||||||
current_role: str = "user",
|
current_role: str = "user",
|
||||||
|
sender_id: str | None = None,
|
||||||
session_summary: str | None = None,
|
session_summary: str | None = None,
|
||||||
|
session_metadata: Mapping[str, Any] | 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_recent_history: bool = True,
|
include_memory_recent_history: bool = True,
|
||||||
@@ -216,6 +219,7 @@ class ContextBuilder:
|
|||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": self.build_system_prompt(
|
"content": self.build_system_prompt(
|
||||||
|
skill_names,
|
||||||
channel=channel,
|
channel=channel,
|
||||||
session_summary=session_summary,
|
session_summary=session_summary,
|
||||||
workspace=root,
|
workspace=root,
|
||||||
|
|||||||
+114
-46
@@ -13,7 +13,7 @@ from dataclasses import dataclass, field
|
|||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
|
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
@@ -103,7 +103,15 @@ if TYPE_CHECKING:
|
|||||||
)
|
)
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
|
|
||||||
_T = TypeVar("_T")
|
class TurnState(Enum):
|
||||||
|
RESTORE = auto()
|
||||||
|
COMPACT = auto()
|
||||||
|
COMMAND = auto()
|
||||||
|
BUILD = auto()
|
||||||
|
RUN = auto()
|
||||||
|
SAVE = auto()
|
||||||
|
RESPOND = auto()
|
||||||
|
DONE = auto()
|
||||||
|
|
||||||
|
|
||||||
class TurnKind(Enum):
|
class TurnKind(Enum):
|
||||||
@@ -111,10 +119,20 @@ class TurnKind(Enum):
|
|||||||
SYSTEM = auto()
|
SYSTEM = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StateTraceEntry:
|
||||||
|
state: TurnState
|
||||||
|
started_at: float
|
||||||
|
duration_ms: float
|
||||||
|
event: str
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TurnContext:
|
class TurnContext:
|
||||||
msg: InboundMessage
|
msg: InboundMessage
|
||||||
session_key: str
|
session_key: str
|
||||||
|
state: TurnState
|
||||||
turn_id: str
|
turn_id: str
|
||||||
runtime: LLMRuntime | None
|
runtime: LLMRuntime | None
|
||||||
kind: TurnKind
|
kind: TurnKind
|
||||||
@@ -128,6 +146,7 @@ class TurnContext:
|
|||||||
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
runtime_context_blocks: list[RuntimeContextBlock] = field(default_factory=list)
|
||||||
|
|
||||||
final_content: str | None = None
|
final_content: str | None = None
|
||||||
|
tools_used: list[str] = field(default_factory=list)
|
||||||
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
all_messages: list[dict[str, Any]] = field(default_factory=list)
|
||||||
stop_reason: str = ""
|
stop_reason: str = ""
|
||||||
had_injections: bool = False
|
had_injections: bool = False
|
||||||
@@ -159,6 +178,8 @@ class TurnContext:
|
|||||||
visible_run_started_at: float | None = None
|
visible_run_started_at: float | None = None
|
||||||
turn_latency_ms: int | None = None
|
turn_latency_ms: int | None = None
|
||||||
|
|
||||||
|
trace: list[StateTraceEntry] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class AgentLoop:
|
class AgentLoop:
|
||||||
"""
|
"""
|
||||||
@@ -223,6 +244,19 @@ class AgentLoop:
|
|||||||
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
_RUNTIME_CHECKPOINT_KEY = "runtime_checkpoint"
|
||||||
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
_PENDING_USER_TURN_KEY = "pending_user_turn"
|
||||||
|
|
||||||
|
# Event-driven state transition table.
|
||||||
|
# Handlers return an event string; the driver looks up the next state here.
|
||||||
|
_TRANSITIONS: dict[tuple[TurnState, str], TurnState] = {
|
||||||
|
(TurnState.RESTORE, "ok"): TurnState.COMPACT,
|
||||||
|
(TurnState.COMPACT, "ok"): TurnState.COMMAND,
|
||||||
|
(TurnState.COMMAND, "dispatch"): TurnState.BUILD,
|
||||||
|
(TurnState.COMMAND, "shortcut"): TurnState.DONE,
|
||||||
|
(TurnState.BUILD, "ok"): TurnState.RUN,
|
||||||
|
(TurnState.RUN, "ok"): TurnState.SAVE,
|
||||||
|
(TurnState.SAVE, "ok"): TurnState.RESPOND,
|
||||||
|
(TurnState.RESPOND, "ok"): TurnState.DONE,
|
||||||
|
}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
bus: MessageBus,
|
bus: MessageBus,
|
||||||
@@ -678,8 +712,13 @@ class AgentLoop:
|
|||||||
current_message=ctx.msg.content,
|
current_message=ctx.msg.content,
|
||||||
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
media=ctx.msg.media if ctx.kind is TurnKind.USER and ctx.msg.media else None,
|
||||||
channel=ctx.delivery.route.channel,
|
channel=ctx.delivery.route.channel,
|
||||||
|
chat_id=str(
|
||||||
|
ctx.msg.metadata.get("context_chat_id") or ctx.delivery.route.chat_id
|
||||||
|
),
|
||||||
current_role="user",
|
current_role="user",
|
||||||
|
sender_id=ctx.msg.sender_id,
|
||||||
session_summary=ctx.pending_summary,
|
session_summary=ctx.pending_summary,
|
||||||
|
session_metadata=ctx.session.metadata,
|
||||||
workspace=scope.project_path,
|
workspace=scope.project_path,
|
||||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||||
include_memory_recent_history=not ctx.ephemeral,
|
include_memory_recent_history=not ctx.ephemeral,
|
||||||
@@ -1340,6 +1379,7 @@ class AgentLoop:
|
|||||||
msg=msg,
|
msg=msg,
|
||||||
session=None,
|
session=None,
|
||||||
session_key=key,
|
session_key=key,
|
||||||
|
state=TurnState.RESTORE,
|
||||||
turn_id=f"{key}:{time.time_ns()}",
|
turn_id=f"{key}:{time.time_ns()}",
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
@@ -1409,47 +1449,65 @@ class AgentLoop:
|
|||||||
ctx.on_stream = _tracked_stream
|
ctx.on_stream = _tracked_stream
|
||||||
ctx.on_stream_end = _tracked_stream_end
|
ctx.on_stream_end = _tracked_stream_end
|
||||||
|
|
||||||
await self._run_turn_stage(ctx, "restore", self._restore_turn)
|
while ctx.state is not TurnState.DONE:
|
||||||
await self._run_turn_stage(ctx, "compact", self._compact_session)
|
handler_name = f"_state_{ctx.state.name.lower()}"
|
||||||
if await self._run_turn_stage(ctx, "command", self._dispatch_command):
|
handler = getattr(self, handler_name, None)
|
||||||
return ctx.outbound
|
if handler is None:
|
||||||
await self._run_turn_stage(ctx, "build", self._build_turn)
|
raise RuntimeError(f"Missing state handler for {ctx.state}")
|
||||||
await self._run_turn_stage(ctx, "run", self._run_turn)
|
|
||||||
await self._run_turn_stage(ctx, "save", self._persist_turn)
|
|
||||||
await self._run_turn_stage(ctx, "respond", self._prepare_outbound)
|
|
||||||
return ctx.outbound
|
|
||||||
|
|
||||||
async def _run_turn_stage(
|
t0 = time.perf_counter()
|
||||||
self,
|
try:
|
||||||
ctx: TurnContext,
|
event = await handler(ctx)
|
||||||
name: str,
|
except Exception:
|
||||||
handler: Callable[[TurnContext], Awaitable[_T]],
|
duration = (time.perf_counter() - t0) * 1000
|
||||||
) -> _T:
|
ctx.trace.append(
|
||||||
started_at = time.perf_counter()
|
StateTraceEntry(
|
||||||
try:
|
state=ctx.state,
|
||||||
result = await handler(ctx)
|
started_at=t0,
|
||||||
except Exception:
|
duration_ms=duration,
|
||||||
duration_ms = (time.perf_counter() - started_at) * 1000
|
event="",
|
||||||
logger.debug(
|
error="exception",
|
||||||
"[turn {}] Stage {} failed after {:.1f}ms",
|
)
|
||||||
ctx.turn_id,
|
)
|
||||||
name,
|
raise
|
||||||
duration_ms,
|
|
||||||
|
duration = (time.perf_counter() - t0) * 1000
|
||||||
|
ctx.trace.append(
|
||||||
|
StateTraceEntry(
|
||||||
|
state=ctx.state,
|
||||||
|
started_at=t0,
|
||||||
|
duration_ms=duration,
|
||||||
|
event=event,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
raise
|
logger.debug(
|
||||||
duration_ms = (time.perf_counter() - started_at) * 1000
|
"[turn {}] State {} took {:.1f}ms -> event {}",
|
||||||
|
ctx.turn_id,
|
||||||
|
ctx.state.name,
|
||||||
|
duration,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
|
||||||
|
next_state = self._TRANSITIONS.get((ctx.state, event))
|
||||||
|
if next_state is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"[turn {ctx.turn_id}] No transition from {ctx.state} "
|
||||||
|
f"on event {event!r}"
|
||||||
|
)
|
||||||
|
ctx.state = next_state
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"[turn {}] Stage {} completed in {:.1f}ms",
|
"[turn {}] Turn completed after {} states",
|
||||||
ctx.turn_id,
|
ctx.turn_id,
|
||||||
name,
|
len(ctx.trace),
|
||||||
duration_ms,
|
|
||||||
)
|
)
|
||||||
return result
|
return ctx.outbound
|
||||||
|
|
||||||
def _assemble_outbound(
|
def _assemble_outbound(
|
||||||
self,
|
self,
|
||||||
msg: InboundMessage,
|
msg: InboundMessage,
|
||||||
final_content: str,
|
final_content: str,
|
||||||
|
all_msgs: list[dict[str, Any]],
|
||||||
stop_reason: str,
|
stop_reason: str,
|
||||||
had_injections: bool,
|
had_injections: bool,
|
||||||
streamed_content: bool,
|
streamed_content: bool,
|
||||||
@@ -1480,7 +1538,7 @@ class AgentLoop:
|
|||||||
metadata=meta,
|
metadata=meta,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _restore_turn(self, ctx: TurnContext) -> None:
|
async def _state_restore(self, ctx: TurnContext) -> TurnState:
|
||||||
"""Restore checkpoint / pending user turn; extract documents."""
|
"""Restore checkpoint / pending user turn; extract documents."""
|
||||||
msg = ctx.msg
|
msg = ctx.msg
|
||||||
|
|
||||||
@@ -1513,6 +1571,8 @@ class AgentLoop:
|
|||||||
if self._restore_pending_user_turn(ctx.session):
|
if self._restore_pending_user_turn(ctx.session):
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
|
|
||||||
|
return "ok"
|
||||||
|
|
||||||
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
def _prepare_message_media(self, content: str, media: list[str]) -> tuple[str, list[str]]:
|
||||||
if self._should_extract_document_text():
|
if self._should_extract_document_text():
|
||||||
return extract_documents(content, media)
|
return extract_documents(content, media)
|
||||||
@@ -1523,13 +1583,14 @@ class AgentLoop:
|
|||||||
return True
|
return True
|
||||||
return self.channels_config.extract_document_text
|
return self.channels_config.extract_document_text
|
||||||
|
|
||||||
async def _compact_session(self, ctx: TurnContext) -> None:
|
async def _state_compact(self, ctx: TurnContext) -> str:
|
||||||
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
|
||||||
ctx.pending_summary = pending
|
ctx.pending_summary = pending
|
||||||
|
return "ok"
|
||||||
|
|
||||||
async def _dispatch_command(self, ctx: TurnContext) -> bool:
|
async def _state_command(self, ctx: TurnContext) -> str:
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
return False
|
return "dispatch"
|
||||||
raw = ctx.msg.content.strip()
|
raw = ctx.msg.content.strip()
|
||||||
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
|
||||||
is_user_turn = (
|
is_user_turn = (
|
||||||
@@ -1565,10 +1626,10 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
return True
|
return "shortcut"
|
||||||
return False
|
return "dispatch"
|
||||||
|
|
||||||
async def _build_turn(self, ctx: TurnContext) -> None:
|
async def _state_build(self, ctx: TurnContext) -> str:
|
||||||
runtime = ctx.runtime
|
runtime = ctx.runtime
|
||||||
if runtime is None:
|
if runtime is None:
|
||||||
runtime = self.runtime_for_session(ctx.session)
|
runtime = self.runtime_for_session(ctx.session)
|
||||||
@@ -1624,7 +1685,9 @@ class AgentLoop:
|
|||||||
if ctx.on_retry_wait is None:
|
if ctx.on_retry_wait is None:
|
||||||
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
|
||||||
|
|
||||||
async def _run_turn(self, ctx: TurnContext) -> None:
|
return "ok"
|
||||||
|
|
||||||
|
async def _state_run(self, ctx: TurnContext) -> str:
|
||||||
if ctx.visible_run_started_at is None:
|
if ctx.visible_run_started_at is None:
|
||||||
ctx.visible_run_started_at = time.time()
|
ctx.visible_run_started_at = time.time()
|
||||||
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
|
||||||
@@ -1651,15 +1714,17 @@ class AgentLoop:
|
|||||||
tools=ctx.tools,
|
tools=ctx.tools,
|
||||||
request_context=ctx.request_context,
|
request_context=ctx.request_context,
|
||||||
)
|
)
|
||||||
final_content, _, all_msgs, stop_reason, had_injections = result
|
final_content, tools_used, all_msgs, stop_reason, had_injections = result
|
||||||
ctx.final_content = final_content
|
ctx.final_content = final_content
|
||||||
|
ctx.tools_used = tools_used
|
||||||
ctx.all_messages = all_msgs
|
ctx.all_messages = all_msgs
|
||||||
ctx.stop_reason = stop_reason
|
ctx.stop_reason = stop_reason
|
||||||
ctx.had_injections = had_injections
|
ctx.had_injections = had_injections
|
||||||
if ctx.kind is TurnKind.USER:
|
if ctx.kind is TurnKind.USER:
|
||||||
await turn_continuation.maybe_continue_turn(ctx)
|
await turn_continuation.maybe_continue_turn(ctx)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
async def _persist_turn(self, ctx: TurnContext) -> None:
|
async def _state_save(self, ctx: TurnContext) -> str:
|
||||||
turn_continuation.prepare_save_boundary(ctx)
|
turn_continuation.prepare_save_boundary(ctx)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -1700,11 +1765,12 @@ class AgentLoop:
|
|||||||
self._clear_pending_user_turn(ctx.session)
|
self._clear_pending_user_turn(ctx.session)
|
||||||
self._clear_runtime_checkpoint(ctx.session)
|
self._clear_runtime_checkpoint(ctx.session)
|
||||||
self.sessions.save(ctx.session)
|
self.sessions.save(ctx.session)
|
||||||
|
return "ok"
|
||||||
|
|
||||||
async def _prepare_outbound(self, ctx: TurnContext) -> None:
|
async def _state_respond(self, ctx: TurnContext) -> str:
|
||||||
if ctx.suppress_response:
|
if ctx.suppress_response:
|
||||||
ctx.outbound = None
|
ctx.outbound = None
|
||||||
return
|
return "ok"
|
||||||
if ctx.kind is TurnKind.SYSTEM:
|
if ctx.kind is TurnKind.SYSTEM:
|
||||||
ctx.outbound = ctx.delivery.background_response(
|
ctx.outbound = ctx.delivery.background_response(
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
@@ -1712,10 +1778,11 @@ class AgentLoop:
|
|||||||
streamed=ctx.streamed_content,
|
streamed=ctx.streamed_content,
|
||||||
latency_ms=ctx.turn_latency_ms,
|
latency_ms=ctx.turn_latency_ms,
|
||||||
)
|
)
|
||||||
return
|
return "ok"
|
||||||
ctx.outbound = self._assemble_outbound(
|
ctx.outbound = self._assemble_outbound(
|
||||||
ctx.msg,
|
ctx.msg,
|
||||||
ctx.final_content,
|
ctx.final_content,
|
||||||
|
ctx.all_messages,
|
||||||
ctx.stop_reason,
|
ctx.stop_reason,
|
||||||
ctx.had_injections,
|
ctx.had_injections,
|
||||||
ctx.streamed_content,
|
ctx.streamed_content,
|
||||||
@@ -1723,6 +1790,7 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
if ctx.ephemeral and ctx.outbound is not None:
|
if ctx.ephemeral and ctx.outbound is not None:
|
||||||
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
|
ctx.outbound.metadata["_stop_reason"] = ctx.stop_reason
|
||||||
|
return "ok"
|
||||||
|
|
||||||
def _sanitize_persisted_blocks(
|
def _sanitize_persisted_blocks(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -912,7 +912,7 @@ class Consolidator:
|
|||||||
) -> tuple[int, str]:
|
) -> tuple[int, str]:
|
||||||
"""Estimate prompt size from the full unconsolidated session tail."""
|
"""Estimate prompt size from the full unconsolidated session tail."""
|
||||||
history = self._full_unconsolidated_history(session)
|
history = self._full_unconsolidated_history(session)
|
||||||
channel = session.key.split(":", 1)[0] if ":" in session.key else None
|
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, 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")
|
||||||
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
|
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
|
||||||
@@ -920,7 +920,10 @@ class Consolidator:
|
|||||||
history=history,
|
history=history,
|
||||||
current_message="[token-probe]",
|
current_message="[token-probe]",
|
||||||
channel=channel,
|
channel=channel,
|
||||||
|
chat_id=chat_id,
|
||||||
|
sender_id=None,
|
||||||
session_summary=summary,
|
session_summary=summary,
|
||||||
|
session_metadata=session.metadata,
|
||||||
session_key=session.key,
|
session_key=session.key,
|
||||||
unified_session=self.unified_session,
|
unified_session=self.unified_session,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1327,7 +1327,7 @@ class AgentRunner:
|
|||||||
return payload, event, exc
|
return payload, event, exc
|
||||||
return payload, event, None
|
return payload, event, None
|
||||||
|
|
||||||
if is_tool_error_result(result):
|
if is_tool_error_result(tool_call.name, result):
|
||||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||||
event = {
|
event = {
|
||||||
"name": tool_call.name,
|
"name": tool_call.name,
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ def _validate_patch_path(path: str) -> str:
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _lines_to_text(lines: list[str]) -> str:
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _text_line_count(text: str) -> int:
|
def _text_line_count(text: str) -> int:
|
||||||
if not text:
|
if not text:
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
|||||||
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
|
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
|
||||||
"Not used for action='list' or action='remove'."
|
"Not used for action='list' or action='remove'."
|
||||||
),
|
),
|
||||||
every_seconds=IntegerSchema(description="Interval in seconds (for recurring tasks)"),
|
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
|
||||||
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
|
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
|
||||||
tz=StringSchema(
|
tz=StringSchema(
|
||||||
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
|
"Optional IANA timezone for cron expressions (e.g. 'America/Vancouver'). "
|
||||||
@@ -138,6 +138,8 @@ class CronTool(Tool):
|
|||||||
tz: str | None = None,
|
tz: str | None = None,
|
||||||
at: str | None = None,
|
at: str | None = None,
|
||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
|
deliver: bool = True,
|
||||||
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
if action == "add":
|
if action == "add":
|
||||||
if self._in_cron_context.get():
|
if self._in_cron_context.get():
|
||||||
|
|||||||
@@ -447,6 +447,7 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
default=False,
|
default=False,
|
||||||
),
|
),
|
||||||
yield_time_ms=IntegerSchema(
|
yield_time_ms=IntegerSchema(
|
||||||
|
DEFAULT_YIELD_MS,
|
||||||
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=MAX_YIELD_MS,
|
maximum=MAX_YIELD_MS,
|
||||||
@@ -457,17 +458,20 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
wait_timeout_ms=IntegerSchema(
|
wait_timeout_ms=IntegerSchema(
|
||||||
|
DEFAULT_WAIT_FOR_MS,
|
||||||
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=MAX_WAIT_FOR_MS,
|
maximum=MAX_WAIT_FOR_MS,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
max_output_chars=IntegerSchema(
|
max_output_chars=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
description="Maximum output characters to return from this poll (default 10000, max 50000).",
|
||||||
minimum=1000,
|
minimum=1000,
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
),
|
),
|
||||||
max_output_tokens=IntegerSchema(
|
max_output_tokens=IntegerSchema(
|
||||||
|
DEFAULT_MAX_OUTPUT_CHARS,
|
||||||
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
|
||||||
minimum=1000,
|
minimum=1000,
|
||||||
maximum=MAX_OUTPUT_CHARS,
|
maximum=MAX_OUTPUT_CHARS,
|
||||||
|
|||||||
@@ -226,10 +226,12 @@ def _builtin_skill_read_path(path: str) -> Path | None:
|
|||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
path=StringSchema("The file path to read"),
|
path=StringSchema("The file path to read"),
|
||||||
offset=IntegerSchema(
|
offset=IntegerSchema(
|
||||||
|
1,
|
||||||
description="Line number to start reading from (1-indexed, default 1)",
|
description="Line number to start reading from (1-indexed, default 1)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
limit=IntegerSchema(
|
limit=IntegerSchema(
|
||||||
|
2000,
|
||||||
description="Maximum number of lines to read (default 2000)",
|
description="Maximum number of lines to read (default 2000)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
@@ -788,11 +790,13 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
new_text=StringSchema("The text to replace with"),
|
new_text=StringSchema("The text to replace with"),
|
||||||
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
|
||||||
occurrence=IntegerSchema(
|
occurrence=IntegerSchema(
|
||||||
|
1,
|
||||||
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
line_hint=IntegerSchema(
|
line_hint=IntegerSchema(
|
||||||
|
1,
|
||||||
description=(
|
description=(
|
||||||
"Optional exact 1-based target line copied from read_file. "
|
"Optional exact 1-based target line copied from read_file. "
|
||||||
"The selected old_text match must cover this line."
|
"The selected old_text match must cover this line."
|
||||||
@@ -801,6 +805,7 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
),
|
),
|
||||||
expected_replacements=IntegerSchema(
|
expected_replacements=IntegerSchema(
|
||||||
|
1,
|
||||||
description="Optional guard for the number of replacements that must be made.",
|
description="Optional guard for the number of replacements that must be made.",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
nullable=True,
|
nullable=True,
|
||||||
@@ -1031,6 +1036,7 @@ class EditFileTool(_FsTool):
|
|||||||
path=StringSchema("The directory path to list"),
|
path=StringSchema("The directory path to list"),
|
||||||
recursive=BooleanSchema(description="Recursively list all files (default false)"),
|
recursive=BooleanSchema(description="Recursively list all files (default false)"),
|
||||||
max_entries=IntegerSchema(
|
max_entries=IntegerSchema(
|
||||||
|
200,
|
||||||
description="Maximum entries to return (default 200)",
|
description="Maximum entries to return (default 200)",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -915,23 +915,6 @@ class MCPPromptWrapper(_MCPWrapperBase):
|
|||||||
return "\n".join(parts) or "(no output)"
|
return "\n".join(parts) or "(no output)"
|
||||||
|
|
||||||
|
|
||||||
def _register_mcp_capability(
|
|
||||||
registry: ToolRegistry,
|
|
||||||
capability: Tool,
|
|
||||||
server_name: str,
|
|
||||||
) -> bool:
|
|
||||||
owner = f"nanobot.mcp.{server_name}"
|
|
||||||
if registry.register_if_absent(capability, owner=owner):
|
|
||||||
return True
|
|
||||||
logger.warning(
|
|
||||||
"MCP: skipping capability '{}' from server '{}' because it is already registered by '{}'",
|
|
||||||
capability.name,
|
|
||||||
server_name,
|
|
||||||
registry.owner(capability.name),
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def connect_mcp_servers(
|
async def connect_mcp_servers(
|
||||||
mcp_servers: dict, registry: ToolRegistry
|
mcp_servers: dict, registry: ToolRegistry
|
||||||
) -> dict[str, MCPConnection]:
|
) -> dict[str, MCPConnection]:
|
||||||
@@ -1065,8 +1048,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
wrapper = MCPToolWrapper(session, name, tool_def, tool_timeout=cfg.tool_timeout)
|
||||||
if not _register_mcp_capability(registry, wrapper, name):
|
registry.register(wrapper)
|
||||||
continue
|
|
||||||
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name)
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
if enabled_tools:
|
if enabled_tools:
|
||||||
@@ -1103,8 +1085,7 @@ async def connect_mcp_servers(
|
|||||||
wrapper = MCPResourceWrapper(
|
wrapper = MCPResourceWrapper(
|
||||||
session, name, resource, resource_timeout=cfg.tool_timeout
|
session, name, resource, resource_timeout=cfg.tool_timeout
|
||||||
)
|
)
|
||||||
if not _register_mcp_capability(registry, wrapper, name):
|
registry.register(wrapper)
|
||||||
continue
|
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"MCP: registered resource '{}' from server '{}'",
|
"MCP: registered resource '{}' from server '{}'",
|
||||||
@@ -1122,8 +1103,7 @@ async def connect_mcp_servers(
|
|||||||
wrapper = MCPPromptWrapper(
|
wrapper = MCPPromptWrapper(
|
||||||
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
session, name, prompt, prompt_timeout=cfg.tool_timeout
|
||||||
)
|
)
|
||||||
if not _register_mcp_capability(registry, wrapper, name):
|
registry.register(wrapper)
|
||||||
continue
|
|
||||||
registered_count += 1
|
registered_count += 1
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"MCP: registered prompt '{}' from server '{}'",
|
"MCP: registered prompt '{}' from server '{}'",
|
||||||
@@ -1293,7 +1273,7 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
|
|||||||
|
|
||||||
tools_removed = 0
|
tools_removed = 0
|
||||||
for name in [*removed, *changed]:
|
for name in [*removed, *changed]:
|
||||||
tools_removed += _unregister_server_tools(registry, name)
|
tools_removed += _unregister_server_tools(state, registry, name)
|
||||||
await _close_server(state, name)
|
await _close_server(state, name)
|
||||||
|
|
||||||
state._mcp_servers = next_servers
|
state._mcp_servers = next_servers
|
||||||
@@ -1467,7 +1447,7 @@ async def _refresh_terminated_server(
|
|||||||
return current_tool
|
return current_tool
|
||||||
|
|
||||||
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
logger.warning("MCP server '{}' session terminated; refreshing connection", server_name)
|
||||||
_unregister_server_tools(registry, server_name)
|
_unregister_server_tools(state, registry, server_name)
|
||||||
await _close_server(state, server_name)
|
await _close_server(state, server_name)
|
||||||
|
|
||||||
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
connected = await connect_mcp_servers({server_name: cfg}, registry)
|
||||||
@@ -1499,7 +1479,7 @@ def _tool_belongs_to_server(tool: Tool | None, tool_name: str, server_name: str)
|
|||||||
return tool_name.startswith(_tool_prefix(server_name))
|
return tool_name.startswith(_tool_prefix(server_name))
|
||||||
|
|
||||||
|
|
||||||
def _unregister_server_tools(registry: ToolRegistry, server_name: str) -> int:
|
def _unregister_server_tools(state: Any, registry: ToolRegistry, server_name: str) -> int:
|
||||||
removed = 0
|
removed = 0
|
||||||
for tool_name in list(registry.tool_names):
|
for tool_name in list(registry.tool_names):
|
||||||
tool = registry.get(tool_name)
|
tool = registry.get(tool_name)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.runtime_context import RuntimeContextProvider
|
from nanobot.runtime_context import RuntimeContextProvider
|
||||||
|
|
||||||
|
|
||||||
def is_tool_error_result(result: Any) -> bool:
|
def is_tool_error_result(name: str, result: Any) -> bool:
|
||||||
return isinstance(result, ToolResult) and result.is_error
|
return isinstance(result, ToolResult) and result.is_error
|
||||||
|
|
||||||
|
|
||||||
@@ -25,44 +25,22 @@ class ToolRegistry:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._tools: dict[str, Tool] = {}
|
self._tools: dict[str, Tool] = {}
|
||||||
self._owners: dict[str, str] = {}
|
|
||||||
self._cached_definitions: list[dict[str, Any]] | None = None
|
self._cached_definitions: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
def register(self, tool: Tool, *, owner: str = "nanobot.core") -> None:
|
def register(self, tool: Tool) -> None:
|
||||||
"""Register a tool."""
|
"""Register a tool."""
|
||||||
self._tools[tool.name] = tool
|
self._tools[tool.name] = tool
|
||||||
self._owners[tool.name] = owner
|
|
||||||
self._cached_definitions = None
|
self._cached_definitions = None
|
||||||
|
|
||||||
def register_if_absent(self, tool: Tool, *, owner: str = "nanobot.core") -> bool:
|
|
||||||
"""Register a tool without replacing an existing capability."""
|
|
||||||
if tool.name in self._tools:
|
|
||||||
return False
|
|
||||||
self.register(tool, owner=owner)
|
|
||||||
return True
|
|
||||||
|
|
||||||
def unregister(self, name: str) -> None:
|
def unregister(self, name: str) -> None:
|
||||||
"""Unregister a tool by name."""
|
"""Unregister a tool by name."""
|
||||||
self._tools.pop(name, None)
|
self._tools.pop(name, None)
|
||||||
self._owners.pop(name, None)
|
|
||||||
self._cached_definitions = None
|
self._cached_definitions = None
|
||||||
|
|
||||||
def unregister_owner(self, owner: str) -> None:
|
|
||||||
"""Remove all tools registered by one extension."""
|
|
||||||
for name in [
|
|
||||||
name for name, registered_owner in self._owners.items()
|
|
||||||
if registered_owner == owner
|
|
||||||
]:
|
|
||||||
self.unregister(name)
|
|
||||||
|
|
||||||
def get(self, name: str) -> Tool | None:
|
def get(self, name: str) -> Tool | None:
|
||||||
"""Get a tool by name."""
|
"""Get a tool by name."""
|
||||||
return self._tools.get(name)
|
return self._tools.get(name)
|
||||||
|
|
||||||
def owner(self, name: str) -> str | None:
|
|
||||||
"""Return the extension ID that registered a tool."""
|
|
||||||
return self._owners.get(name)
|
|
||||||
|
|
||||||
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
def get_runtime_context_providers(self) -> list[RuntimeContextProvider]:
|
||||||
"""Return tool-owned providers in stable tool-name order."""
|
"""Return tool-owned providers in stable tool-name order."""
|
||||||
providers: list[RuntimeContextProvider] = []
|
providers: list[RuntimeContextProvider] = []
|
||||||
@@ -215,7 +193,7 @@ class ToolRegistry:
|
|||||||
try:
|
try:
|
||||||
assert tool is not None # guarded by prepare_call()
|
assert tool is not None # guarded by prepare_call()
|
||||||
result = await tool.execute(**params)
|
result = await tool.execute(**params)
|
||||||
if is_tool_error_result(result):
|
if is_tool_error_result(name, result):
|
||||||
return ToolResult.error(str(result) + hint)
|
return ToolResult.error(str(result) + hint)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ class StringSchema(Schema):
|
|||||||
|
|
||||||
|
|
||||||
class IntegerSchema(Schema):
|
class IntegerSchema(Schema):
|
||||||
"""Integer parameter with a description and optional bounds."""
|
"""Integer parameter: optional placeholder int (legacy ctor signature), description, and bounds."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
value: int = 0,
|
||||||
*,
|
*,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
minimum: int | None = None,
|
minimum: int | None = None,
|
||||||
@@ -63,6 +64,7 @@ class IntegerSchema(Schema):
|
|||||||
enum: tuple[int, ...] | list[int] | None = None,
|
enum: tuple[int, ...] | list[int] | None = None,
|
||||||
nullable: bool = False,
|
nullable: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self._value = value
|
||||||
self._description = description
|
self._description = description
|
||||||
self._minimum = minimum
|
self._minimum = minimum
|
||||||
self._maximum = maximum
|
self._maximum = maximum
|
||||||
@@ -90,6 +92,7 @@ class NumberSchema(Schema):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
value: float = 0.0,
|
||||||
*,
|
*,
|
||||||
description: str = "",
|
description: str = "",
|
||||||
minimum: float | None = None,
|
minimum: float | None = None,
|
||||||
@@ -97,6 +100,7 @@ class NumberSchema(Schema):
|
|||||||
enum: tuple[float, ...] | list[float] | None = None,
|
enum: tuple[float, ...] | list[float] | None = None,
|
||||||
nullable: bool = False,
|
nullable: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self._value = value
|
||||||
self._description = description
|
self._description = description
|
||||||
self._minimum = minimum
|
self._minimum = minimum
|
||||||
self._maximum = maximum
|
self._maximum = maximum
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class _PreparedCommand:
|
|||||||
working_dir=StringSchema("Optional working directory for the command"),
|
working_dir=StringSchema("Optional working directory for the command"),
|
||||||
workdir=StringSchema("Compatibility alias for working_dir"),
|
workdir=StringSchema("Compatibility alias for working_dir"),
|
||||||
timeout=IntegerSchema(
|
timeout=IntegerSchema(
|
||||||
|
60,
|
||||||
description=(
|
description=(
|
||||||
"Timeout in seconds. Increase for long-running commands "
|
"Timeout in seconds. Increase for long-running commands "
|
||||||
"like compilation or installation (default 60, max 600)."
|
"like compilation or installation (default 60, max 600)."
|
||||||
|
|||||||
@@ -271,12 +271,13 @@ def _normalize_volcengine_auth_level(value: Any) -> int | None:
|
|||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
query=StringSchema("Search query"),
|
query=StringSchema("Search query"),
|
||||||
count=IntegerSchema(description="Results (1-10)", minimum=1, maximum=10),
|
count=IntegerSchema(1, description="Results (1-10)", minimum=1, maximum=10),
|
||||||
timeRange=StringSchema(
|
timeRange=StringSchema(
|
||||||
"Optional time filter for providers that support it: "
|
"Optional time filter for providers that support it: "
|
||||||
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
|
"OneDay, OneWeek, OneMonth, OneYear, or YYYY-MM-DD..YYYY-MM-DD",
|
||||||
),
|
),
|
||||||
authLevel=IntegerSchema(
|
authLevel=IntegerSchema(
|
||||||
|
0,
|
||||||
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
|
description="Optional authority filter for providers that support it: 0=all, 1=authoritative",
|
||||||
minimum=0,
|
minimum=0,
|
||||||
maximum=1,
|
maximum=1,
|
||||||
@@ -938,7 +939,7 @@ class WebSearchTool(Tool):
|
|||||||
"enum": ["markdown", "text"],
|
"enum": ["markdown", "text"],
|
||||||
"default": "markdown",
|
"default": "markdown",
|
||||||
},
|
},
|
||||||
maxChars=IntegerSchema(minimum=100),
|
maxChars=IntegerSchema(0, minimum=100),
|
||||||
required=["url"],
|
required=["url"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ class ChannelManager:
|
|||||||
webui_static_dist: bool = True,
|
webui_static_dist: bool = True,
|
||||||
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_extension_service: Any | None = None,
|
|
||||||
):
|
):
|
||||||
self.config = config
|
self.config = config
|
||||||
self.bus = bus
|
self.bus = bus
|
||||||
@@ -111,7 +110,6 @@ class ChannelManager:
|
|||||||
self._webui_static_dist = webui_static_dist
|
self._webui_static_dist = webui_static_dist
|
||||||
self._webui_runtime_surface = webui_runtime_surface
|
self._webui_runtime_surface = webui_runtime_surface
|
||||||
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
|
||||||
self._webui_extension_service = webui_extension_service
|
|
||||||
self.channels: dict[str, BaseChannel] = {}
|
self.channels: dict[str, BaseChannel] = {}
|
||||||
self._channel_owners: dict[str, str] = {}
|
self._channel_owners: dict[str, str] = {}
|
||||||
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
self._channel_runtime_specs: dict[str, tuple[str, str]] = {}
|
||||||
@@ -178,10 +176,6 @@ class ChannelManager:
|
|||||||
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
|
||||||
channel_feature_action=self.apply_channel_feature_action,
|
channel_feature_action=self.apply_channel_feature_action,
|
||||||
channel_runtime_status=self.get_status,
|
channel_runtime_status=self.get_status,
|
||||||
extension_service=self._webui_extension_service,
|
|
||||||
allow_remote_package_install=(
|
|
||||||
self.config.tools.webui_allow_remote_package_install
|
|
||||||
),
|
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
kwargs["gateway"] = gateway
|
kwargs["gateway"] = gateway
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
from functools import cache
|
||||||
|
from importlib.metadata import entry_points
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
@@ -17,6 +19,22 @@ if TYPE_CHECKING:
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def _warn_legacy_channel_entry_points() -> None:
|
||||||
|
# TODO(v0.3.1): Remove this detection and warning. v0.3.0 is the final
|
||||||
|
# migration window for installed legacy channel entry points.
|
||||||
|
names = sorted({entry_point.name for entry_point in entry_points(group="nanobot.channels")})
|
||||||
|
if not names:
|
||||||
|
return
|
||||||
|
logger.warning(
|
||||||
|
"Legacy channel entry points were detected but will not be loaded: {}. "
|
||||||
|
"The '{}' entry-point group is no longer supported; use a built-in channel or "
|
||||||
|
"migrate it into nanobot/channels/<channel>/.",
|
||||||
|
", ".join(names),
|
||||||
|
"nanobot.channels",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _channel_package_names() -> list[str]:
|
def _channel_package_names() -> list[str]:
|
||||||
import nanobot.channels as package
|
import nanobot.channels as package
|
||||||
|
|
||||||
@@ -31,6 +49,7 @@ def discover_plugins(
|
|||||||
enabled_names: set[str] | None = None,
|
enabled_names: set[str] | None = None,
|
||||||
) -> dict[str, ChannelPlugin]:
|
) -> dict[str, ChannelPlugin]:
|
||||||
"""Load dependency-free descriptors from self-contained channel packages."""
|
"""Load dependency-free descriptors from self-contained channel packages."""
|
||||||
|
_warn_legacy_channel_entry_points()
|
||||||
plugins: dict[str, ChannelPlugin] = {}
|
plugins: dict[str, ChannelPlugin] = {}
|
||||||
for name in _channel_package_names():
|
for name in _channel_package_names():
|
||||||
if enabled_names is not None and name not in enabled_names:
|
if enabled_names is not None and name not in enabled_names:
|
||||||
|
|||||||
@@ -1582,6 +1582,49 @@ async def test_stream_transcript_persists_without_subscribers() -> None:
|
|||||||
assert body["messages"][-1]["latencyMs"] == 42
|
assert body["messages"][-1]["latencyMs"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cron_reply_persists_for_replay_without_subscribers() -> None:
|
||||||
|
from nanobot.cron.webui_metadata import cron_proactive_delivery_metadata
|
||||||
|
from nanobot.webui.transcript import build_webui_thread_response
|
||||||
|
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
metadata = cron_proactive_delivery_metadata(
|
||||||
|
"websocket",
|
||||||
|
None,
|
||||||
|
turn_seed="cron:daily-digest",
|
||||||
|
source_label="Daily digest",
|
||||||
|
)
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="cron-offline",
|
||||||
|
content="The scheduled digest is ready.",
|
||||||
|
metadata=metadata,
|
||||||
|
))
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="cron-offline",
|
||||||
|
content="",
|
||||||
|
event=TurnEndEvent(),
|
||||||
|
metadata=metadata,
|
||||||
|
))
|
||||||
|
|
||||||
|
assert channel._subs == {}
|
||||||
|
body = build_webui_thread_response("websocket:cron-offline")
|
||||||
|
assert body is not None
|
||||||
|
assert body["messages"][-1]["role"] == "assistant"
|
||||||
|
assert body["messages"][-1]["content"] == "The scheduled digest is ready."
|
||||||
|
assert body["messages"][-1]["source"] == {
|
||||||
|
"kind": "cron",
|
||||||
|
"label": "Daily digest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_turn_end_emits_turn_end_event() -> None:
|
async def test_send_turn_end_emits_turn_end_event() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@@ -1891,11 +1891,15 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
|||||||
assert initial.status_code == 200
|
assert initial.status_code == 200
|
||||||
assert initial.json()["schema_version"] == 1
|
assert initial.json()["schema_version"] == 1
|
||||||
assert initial.json()["pinned_keys"] == []
|
assert initial.json()["pinned_keys"] == []
|
||||||
|
assert initial.json()["activity_seen_at_by_key"] == {}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"pinned_keys": ["websocket:sidebar"],
|
"pinned_keys": ["websocket:sidebar"],
|
||||||
"archived_keys": ["websocket:old"],
|
"archived_keys": ["websocket:old"],
|
||||||
"title_overrides": {"websocket:sidebar": "Pinned work"},
|
"title_overrides": {"websocket:sidebar": "Pinned work"},
|
||||||
|
"activity_seen_at_by_key": {
|
||||||
|
"websocket:sidebar": "2026-07-27T08:30:00Z"
|
||||||
|
},
|
||||||
"view": {"density": "compact", "show_archived": True},
|
"view": {"density": "compact", "show_archived": True},
|
||||||
}
|
}
|
||||||
query = urlencode({"state": json.dumps(payload)})
|
query = urlencode({"state": json.dumps(payload)})
|
||||||
@@ -1907,6 +1911,9 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
|||||||
body = updated.json()
|
body = updated.json()
|
||||||
assert body["pinned_keys"] == ["websocket:sidebar"]
|
assert body["pinned_keys"] == ["websocket:sidebar"]
|
||||||
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
|
assert body["title_overrides"] == {"websocket:sidebar": "Pinned work"}
|
||||||
|
assert body["activity_seen_at_by_key"] == {
|
||||||
|
"websocket:sidebar": "2026-07-27T08:30:00Z"
|
||||||
|
}
|
||||||
assert body["view"]["density"] == "compact"
|
assert body["view"]["density"] == "compact"
|
||||||
|
|
||||||
state_path = tmp_path / "webui" / "sidebar-state.json"
|
state_path = tmp_path / "webui" / "sidebar-state.json"
|
||||||
@@ -1914,6 +1921,9 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
|
|||||||
assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
|
assert json.loads(state_path.read_text(encoding="utf-8"))["pinned_keys"] == [
|
||||||
"websocket:sidebar"
|
"websocket:sidebar"
|
||||||
]
|
]
|
||||||
|
assert json.loads(state_path.read_text(encoding="utf-8"))[
|
||||||
|
"activity_seen_at_by_key"
|
||||||
|
] == {"websocket:sidebar": "2026-07-27T08:30:00Z"}
|
||||||
finally:
|
finally:
|
||||||
await channel.stop()
|
await channel.stop()
|
||||||
await server_task
|
await server_task
|
||||||
|
|||||||
+44
-55
@@ -72,7 +72,6 @@ from nanobot.bus.outbound_events import ( # noqa: E402
|
|||||||
StreamEndEvent,
|
StreamEndEvent,
|
||||||
outbound_event_from_message,
|
outbound_event_from_message,
|
||||||
)
|
)
|
||||||
from nanobot.cli.extensions import create_extensions_app # noqa: E402
|
|
||||||
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||||
@@ -816,6 +815,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
console.print(f"[red]Error: {e}[/red]")
|
console.print(f"[red]Error: {e}[/red]")
|
||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
_warn_deprecated_config_keys(config_path)
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return loaded
|
return loaded
|
||||||
@@ -836,6 +836,24 @@ def _read_trigger_cli_message(message: str | None) -> str:
|
|||||||
raise typer.Exit(1)
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_deprecated_config_keys(config_path: Path | None) -> None:
|
||||||
|
"""Hint users to remove obsolete keys from their config file."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from nanobot.config.loader import get_config_path
|
||||||
|
|
||||||
|
path = config_path or get_config_path()
|
||||||
|
try:
|
||||||
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if "memoryWindow" in raw.get("agents", {}).get("defaults", {}):
|
||||||
|
console.print(
|
||||||
|
"[dim]Hint: `memoryWindow` in your config is no longer used "
|
||||||
|
"and can be safely removed.[/dim]"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load_inspection_config(
|
def _load_inspection_config(
|
||||||
config: str | None = None,
|
config: str | None = None,
|
||||||
workspace: str | None = None,
|
workspace: str | None = None,
|
||||||
@@ -855,6 +873,7 @@ def _load_inspection_config(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
|
_warn_deprecated_config_keys(display_path)
|
||||||
if workspace:
|
if workspace:
|
||||||
loaded.agents.defaults.workspace = workspace
|
loaded.agents.defaults.workspace = workspace
|
||||||
return display_path, loaded
|
return display_path, loaded
|
||||||
@@ -1329,7 +1348,6 @@ def serve(
|
|||||||
|
|
||||||
from nanobot.api.server import create_app
|
from nanobot.api.server import create_app
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
@@ -1378,17 +1396,12 @@ def serve(
|
|||||||
agent_loop, model_name=model_name, request_timeout=timeout,
|
agent_loop, model_name=model_name, request_timeout=timeout,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
)
|
)
|
||||||
extension_host = ExtensionHost(agent_loop, lambda: runtime_config)
|
|
||||||
|
|
||||||
async def on_startup(_app):
|
async def on_startup(_app):
|
||||||
await agent_loop._connect_mcp()
|
await agent_loop._connect_mcp()
|
||||||
await extension_host.reload()
|
|
||||||
|
|
||||||
async def on_cleanup(_app):
|
async def on_cleanup(_app):
|
||||||
try:
|
await agent_loop.close_mcp()
|
||||||
await extension_host.close()
|
|
||||||
finally:
|
|
||||||
await agent_loop.close_mcp()
|
|
||||||
|
|
||||||
api_app.on_startup.append(on_startup)
|
api_app.on_startup.append(on_startup)
|
||||||
api_app.on_cleanup.append(on_cleanup)
|
api_app.on_cleanup.append(on_cleanup)
|
||||||
@@ -1632,8 +1645,6 @@ def _run_gateway(
|
|||||||
from nanobot.cron.service import CronJobSkippedError, CronService
|
from nanobot.cron.service import CronJobSkippedError, CronService
|
||||||
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
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.extensions.service import ExtensionService
|
|
||||||
from nanobot.providers.factory import (
|
from nanobot.providers.factory import (
|
||||||
build_provider_snapshot,
|
build_provider_snapshot,
|
||||||
build_unconfigured_provider_snapshot,
|
build_unconfigured_provider_snapshot,
|
||||||
@@ -1753,8 +1764,6 @@ def _run_gateway(
|
|||||||
local_trigger_store=trigger_store,
|
local_trigger_store=trigger_store,
|
||||||
hook_factories=[create_file_edit_activity_hook],
|
hook_factories=[create_file_edit_activity_hook],
|
||||||
)
|
)
|
||||||
extension_host = ExtensionHost(agent, lambda: config)
|
|
||||||
extension_service = ExtensionService(host=extension_host)
|
|
||||||
webui_turn_coordinator = WebuiTurnCoordinator(
|
webui_turn_coordinator = WebuiTurnCoordinator(
|
||||||
bus=bus,
|
bus=bus,
|
||||||
sessions=session_manager,
|
sessions=session_manager,
|
||||||
@@ -1989,7 +1998,6 @@ def _run_gateway(
|
|||||||
webui_static_dist=webui_static_dist,
|
webui_static_dist=webui_static_dist,
|
||||||
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_extension_service=extension_service,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _pick_heartbeat_target() -> tuple[str, str]:
|
def _pick_heartbeat_target() -> tuple[str, str]:
|
||||||
@@ -2145,7 +2153,6 @@ def _run_gateway(
|
|||||||
console.print,
|
console.print,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await extension_host.reload()
|
|
||||||
await cron.start()
|
await cron.start()
|
||||||
# Re-read once on first admission to close the watcher subscription window.
|
# Re-read once on first admission to close the watcher subscription window.
|
||||||
agent.runtime_resolver.invalidate()
|
agent.runtime_resolver.invalidate()
|
||||||
@@ -2225,10 +2232,7 @@ def _run_gateway(
|
|||||||
if flushed:
|
if flushed:
|
||||||
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
|
||||||
finally:
|
finally:
|
||||||
try:
|
restore_shutdown_handlers()
|
||||||
await extension_host.close()
|
|
||||||
finally:
|
|
||||||
restore_shutdown_handlers()
|
|
||||||
|
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
@@ -2265,7 +2269,6 @@ def agent(
|
|||||||
"""Interact with the agent directly."""
|
"""Interact with the agent directly."""
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
|
|
||||||
config = _load_runtime_config(config, workspace)
|
config = _load_runtime_config(config, workspace)
|
||||||
@@ -2293,7 +2296,6 @@ def agent(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
console.print(f"[red]Error: {exc}[/red]")
|
||||||
raise typer.Exit(1) from exc
|
raise typer.Exit(1) from exc
|
||||||
extension_host = ExtensionHost(agent_loop, lambda: config)
|
|
||||||
restart_notice = consume_restart_notice_from_env()
|
restart_notice = consume_restart_notice_from_env()
|
||||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||||
_print_agent_response(
|
_print_agent_response(
|
||||||
@@ -2335,36 +2337,29 @@ def agent(
|
|||||||
if message:
|
if message:
|
||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once():
|
async def run_once():
|
||||||
try:
|
renderer = StreamRenderer(
|
||||||
await extension_host.reload()
|
render_markdown=markdown,
|
||||||
renderer = StreamRenderer(
|
bot_name=config.agents.defaults.bot_name,
|
||||||
|
bot_icon=config.agents.defaults.bot_icon,
|
||||||
|
)
|
||||||
|
response = await agent_loop.process_direct(
|
||||||
|
message, session_id,
|
||||||
|
on_progress=_make_progress(renderer),
|
||||||
|
on_stream=renderer.on_delta,
|
||||||
|
on_stream_end=renderer.on_end,
|
||||||
|
)
|
||||||
|
if not renderer.streamed:
|
||||||
|
await renderer.close()
|
||||||
|
print_kwargs: dict[str, Any] = {}
|
||||||
|
if renderer.header_printed:
|
||||||
|
print_kwargs["show_header"] = False
|
||||||
|
_print_agent_response(
|
||||||
|
response.content if response else "",
|
||||||
render_markdown=markdown,
|
render_markdown=markdown,
|
||||||
bot_name=config.agents.defaults.bot_name,
|
metadata=response.metadata if response else None,
|
||||||
bot_icon=config.agents.defaults.bot_icon,
|
**print_kwargs,
|
||||||
)
|
)
|
||||||
response = await agent_loop.process_direct(
|
await agent_loop.close_mcp()
|
||||||
message,
|
|
||||||
session_id,
|
|
||||||
on_progress=_make_progress(renderer),
|
|
||||||
on_stream=renderer.on_delta,
|
|
||||||
on_stream_end=renderer.on_end,
|
|
||||||
)
|
|
||||||
if not renderer.streamed:
|
|
||||||
await renderer.close()
|
|
||||||
print_kwargs: dict[str, Any] = {}
|
|
||||||
if renderer.header_printed:
|
|
||||||
print_kwargs["show_header"] = False
|
|
||||||
_print_agent_response(
|
|
||||||
response.content if response else "",
|
|
||||||
render_markdown=markdown,
|
|
||||||
metadata=response.metadata if response else None,
|
|
||||||
**print_kwargs,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
await agent_loop.close_mcp()
|
|
||||||
finally:
|
|
||||||
await extension_host.close()
|
|
||||||
|
|
||||||
asyncio.run(run_once())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
@@ -2397,7 +2392,6 @@ def agent(
|
|||||||
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive():
|
||||||
await extension_host.reload()
|
|
||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
turn_done = asyncio.Event()
|
turn_done = asyncio.Event()
|
||||||
turn_done.set()
|
turn_done.set()
|
||||||
@@ -2529,10 +2523,7 @@ def agent(
|
|||||||
agent_loop.stop()
|
agent_loop.stop()
|
||||||
outbound_task.cancel()
|
outbound_task.cancel()
|
||||||
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
await asyncio.gather(bus_task, outbound_task, return_exceptions=True)
|
||||||
try:
|
await agent_loop.close_mcp()
|
||||||
await agent_loop.close_mcp()
|
|
||||||
finally:
|
|
||||||
await extension_host.close()
|
|
||||||
|
|
||||||
asyncio.run(run_interactive())
|
asyncio.run(run_interactive())
|
||||||
|
|
||||||
@@ -2542,8 +2533,6 @@ def agent(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
app.add_typer(create_extensions_app(console=console), name="extensions")
|
|
||||||
|
|
||||||
channels_app = typer.Typer(help="Manage channels")
|
channels_app = typer.Typer(help="Manage channels")
|
||||||
app.add_typer(channels_app, name="channels")
|
app.add_typer(channels_app, name="channels")
|
||||||
|
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
"""Typer commands for installing and governing extensions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import typer
|
|
||||||
from rich.console import Console
|
|
||||||
from rich.table import Table
|
|
||||||
|
|
||||||
from nanobot.extensions.service import ExtensionService
|
|
||||||
|
|
||||||
ServiceFactory = Callable[[], ExtensionService]
|
|
||||||
|
|
||||||
|
|
||||||
def create_extensions_app(
|
|
||||||
*,
|
|
||||||
console: Console,
|
|
||||||
service_factory: ServiceFactory = ExtensionService,
|
|
||||||
) -> typer.Typer:
|
|
||||||
"""Build the extension command group around the transport-neutral service."""
|
|
||||||
app = typer.Typer(help="Install, inspect, and govern native extensions.")
|
|
||||||
|
|
||||||
def service() -> ExtensionService:
|
|
||||||
return service_factory()
|
|
||||||
|
|
||||||
def run(awaitable: Any) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
return asyncio.run(awaitable)
|
|
||||||
except (KeyError, RuntimeError, ValueError) as exc:
|
|
||||||
console.print(f"[red]Error: {exc}[/red]")
|
|
||||||
raise typer.Exit(1) from exc
|
|
||||||
|
|
||||||
@app.command("list")
|
|
||||||
def list_extensions() -> None:
|
|
||||||
"""List installed extensions and their activation policy."""
|
|
||||||
payload = run(service().status())
|
|
||||||
table = Table(show_header=True, header_style="bold")
|
|
||||||
table.add_column("Extension")
|
|
||||||
table.add_column("State")
|
|
||||||
table.add_column("Trust")
|
|
||||||
table.add_column("Version")
|
|
||||||
for item in payload["extensions"]:
|
|
||||||
state = "active" if item["active"] else ("enabled" if item["enabled"] else "disabled")
|
|
||||||
table.add_row(
|
|
||||||
item["name"],
|
|
||||||
state,
|
|
||||||
"trusted" if item["trusted"] else "untrusted",
|
|
||||||
item["version"],
|
|
||||||
)
|
|
||||||
console.print(table)
|
|
||||||
if not payload["extensions"]:
|
|
||||||
console.print("[dim]No extensions installed.[/dim]")
|
|
||||||
if payload["diagnostics"]:
|
|
||||||
console.print(f"[yellow]{len(payload['diagnostics'])} diagnostic(s)[/yellow]")
|
|
||||||
|
|
||||||
@app.command("inspect")
|
|
||||||
def inspect_extension(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
|
|
||||||
"""Show manifest, dependencies, permissions, and diagnostics."""
|
|
||||||
payload = run(service().status())
|
|
||||||
item = next(
|
|
||||||
(candidate for candidate in payload["extensions"] if candidate["id"] == extension_id),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if item is None:
|
|
||||||
console.print(f"[red]Extension not found: {extension_id}[/red]")
|
|
||||||
raise typer.Exit(1)
|
|
||||||
console.print(f"[bold]{item['name']}[/bold] [dim]{item['version']}[/dim]")
|
|
||||||
console.print(item["description"] or "[dim]No description.[/dim]")
|
|
||||||
console.print(
|
|
||||||
f"State: {'active' if item['active'] else 'inactive'} "
|
|
||||||
f"Trust: {'trusted' if item['trusted'] else 'untrusted'}"
|
|
||||||
)
|
|
||||||
_print_named_rows(console, "Dependencies", item["dependencies"], "kind", "name")
|
|
||||||
_print_permissions(console, item["permissions"], set(item["granted_permissions"]))
|
|
||||||
diagnostics = [
|
|
||||||
diagnostic
|
|
||||||
for diagnostic in payload["diagnostics"]
|
|
||||||
if diagnostic["extension_id"] == extension_id
|
|
||||||
]
|
|
||||||
if diagnostics:
|
|
||||||
console.print("\n[bold]Diagnostics[/bold]")
|
|
||||||
for diagnostic in diagnostics:
|
|
||||||
console.print(
|
|
||||||
f" [yellow]{diagnostic['code']}[/yellow] {diagnostic['message']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.command("install")
|
|
||||||
def install_extension(
|
|
||||||
source: str = typer.Argument(..., help="Git URL or local package path"),
|
|
||||||
kind: str = typer.Option("git", "--kind", help="git or local"),
|
|
||||||
ref: str = typer.Option("", "--ref", help="Git branch, tag, or commit"),
|
|
||||||
) -> None:
|
|
||||||
"""Install an extension without granting trust or permissions."""
|
|
||||||
payload = run(service().install(source, kind=kind, ref=ref, trusted=False))
|
|
||||||
record = payload["record"]
|
|
||||||
console.print(
|
|
||||||
f"[green]Installed {record['id']} {record['version']}[/green] "
|
|
||||||
"[yellow](untrusted)[/yellow]"
|
|
||||||
)
|
|
||||||
console.print(
|
|
||||||
f"Review with [bold]nanobot extensions inspect {record['id']}[/bold], "
|
|
||||||
"then grant permissions and trust it explicitly."
|
|
||||||
)
|
|
||||||
|
|
||||||
def policy_command(name: str, value: bool, label: str, help_text: str) -> None:
|
|
||||||
@app.command(name, help=help_text)
|
|
||||||
def update(extension_id: str = typer.Argument(..., help="Extension ID")) -> None:
|
|
||||||
payload = run(
|
|
||||||
service().set_enabled(extension_id, value)
|
|
||||||
if name in {"enable", "disable"}
|
|
||||||
else service().set_trusted(extension_id, value)
|
|
||||||
)
|
|
||||||
console.print(f"[green]{label}: {payload['record']['id']}[/green]")
|
|
||||||
|
|
||||||
policy_command("enable", True, "Enabled", "Allow an installed extension to activate.")
|
|
||||||
policy_command("disable", False, "Disabled", "Prevent an installed extension from activating.")
|
|
||||||
policy_command("trust", True, "Trusted", "Trust an installed extension's executable code.")
|
|
||||||
policy_command("untrust", False, "Trust revoked", "Revoke trust and stop extension activation.")
|
|
||||||
|
|
||||||
@app.command("permissions")
|
|
||||||
def set_permissions(
|
|
||||||
extension_id: str = typer.Argument(..., help="Extension ID"),
|
|
||||||
permissions: list[str] = typer.Argument(
|
|
||||||
None,
|
|
||||||
help="Exact permissions to grant; omit all to revoke every grant",
|
|
||||||
),
|
|
||||||
) -> None:
|
|
||||||
"""Replace the extension's granted host permissions."""
|
|
||||||
payload = run(service().set_permissions(extension_id, set(permissions or [])))
|
|
||||||
granted = payload["record"]["granted_permissions"]
|
|
||||||
console.print(
|
|
||||||
f"[green]Updated permissions for {extension_id}:[/green] "
|
|
||||||
+ (", ".join(granted) if granted else "none")
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.command("uninstall")
|
|
||||||
def uninstall_extension(
|
|
||||||
extension_id: str = typer.Argument(..., help="Extension ID"),
|
|
||||||
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
|
|
||||||
) -> None:
|
|
||||||
"""Remove an installed extension."""
|
|
||||||
if not yes and not typer.confirm(f"Uninstall extension '{extension_id}'?"):
|
|
||||||
raise typer.Abort()
|
|
||||||
run(service().uninstall(extension_id))
|
|
||||||
console.print(f"[green]Uninstalled {extension_id}[/green]")
|
|
||||||
|
|
||||||
return app
|
|
||||||
|
|
||||||
|
|
||||||
def _print_named_rows(
|
|
||||||
console: Console,
|
|
||||||
title: str,
|
|
||||||
rows: list[dict[str, Any]],
|
|
||||||
category_key: str,
|
|
||||||
name_key: str,
|
|
||||||
) -> None:
|
|
||||||
console.print(f"\n[bold]{title}[/bold]")
|
|
||||||
if not rows:
|
|
||||||
console.print(" [dim]None[/dim]")
|
|
||||||
return
|
|
||||||
for row in rows:
|
|
||||||
console.print(f" {row[category_key]}: {row[name_key]}")
|
|
||||||
|
|
||||||
|
|
||||||
def _print_permissions(
|
|
||||||
console: Console,
|
|
||||||
permissions: list[dict[str, str]],
|
|
||||||
granted: set[str],
|
|
||||||
) -> None:
|
|
||||||
console.print("\n[bold]Permissions[/bold]")
|
|
||||||
if not permissions:
|
|
||||||
console.print(" [dim]None requested[/dim]")
|
|
||||||
return
|
|
||||||
for permission in permissions:
|
|
||||||
status = "[green]granted[/green]" if permission["name"] in granted else "[yellow]pending[/yellow]"
|
|
||||||
reason = f" — {permission['reason']}" if permission["reason"] else ""
|
|
||||||
console.print(f" {permission['name']} ({status}){reason}")
|
|
||||||
@@ -64,57 +64,16 @@ class CommandRouter:
|
|||||||
self._priority: dict[str, Handler] = {}
|
self._priority: dict[str, Handler] = {}
|
||||||
self._exact: dict[str, Handler] = {}
|
self._exact: dict[str, Handler] = {}
|
||||||
self._prefix: list[tuple[str, Handler]] = []
|
self._prefix: list[tuple[str, Handler]] = []
|
||||||
self._owners: dict[tuple[str, str], str] = {}
|
|
||||||
|
|
||||||
def priority(
|
def priority(self, cmd: str, handler: Handler) -> None:
|
||||||
self,
|
|
||||||
cmd: str,
|
|
||||||
handler: Handler,
|
|
||||||
*,
|
|
||||||
owner: str = "nanobot.core",
|
|
||||||
) -> None:
|
|
||||||
self._priority[cmd] = handler
|
self._priority[cmd] = handler
|
||||||
self._owners[("priority", cmd)] = owner
|
|
||||||
|
|
||||||
def exact(
|
def exact(self, cmd: str, handler: Handler) -> None:
|
||||||
self,
|
|
||||||
cmd: str,
|
|
||||||
handler: Handler,
|
|
||||||
*,
|
|
||||||
owner: str = "nanobot.core",
|
|
||||||
) -> None:
|
|
||||||
self._exact[cmd] = handler
|
self._exact[cmd] = handler
|
||||||
self._owners[("exact", cmd)] = owner
|
|
||||||
|
|
||||||
def prefix(
|
def prefix(self, pfx: str, handler: Handler) -> None:
|
||||||
self,
|
|
||||||
pfx: str,
|
|
||||||
handler: Handler,
|
|
||||||
*,
|
|
||||||
owner: str = "nanobot.core",
|
|
||||||
) -> None:
|
|
||||||
self._prefix.append((pfx, handler))
|
self._prefix.append((pfx, handler))
|
||||||
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
|
||||||
self._owners[("prefix", pfx)] = owner
|
|
||||||
|
|
||||||
def owner(self, tier: str, command: str) -> str | None:
|
|
||||||
"""Return the extension that owns one command registration."""
|
|
||||||
return self._owners.get((tier, command))
|
|
||||||
|
|
||||||
def unregister_owner(self, owner: str) -> None:
|
|
||||||
"""Remove all command tiers registered by one extension."""
|
|
||||||
for (tier, command), registered_owner in list(self._owners.items()):
|
|
||||||
if registered_owner != owner:
|
|
||||||
continue
|
|
||||||
if tier == "priority":
|
|
||||||
self._priority.pop(command, None)
|
|
||||||
elif tier == "exact":
|
|
||||||
self._exact.pop(command, None)
|
|
||||||
else:
|
|
||||||
self._prefix = [
|
|
||||||
item for item in self._prefix if item[0] != command
|
|
||||||
]
|
|
||||||
self._owners.pop((tier, command), None)
|
|
||||||
|
|
||||||
def is_priority(self, text: str) -> bool:
|
def is_priority(self, text: str) -> bool:
|
||||||
return normalize_command_text(text).lower() in self._priority
|
return normalize_command_text(text).lower() in self._priority
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
from loguru import logger
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
from nanobot.config.schema import Config, _resolve_tool_config_refs
|
||||||
@@ -199,6 +200,23 @@ def _env_replace(match: re.Match[str]) -> str:
|
|||||||
|
|
||||||
def _migrate_config(data: dict) -> dict:
|
def _migrate_config(data: dict) -> dict:
|
||||||
"""Migrate old config formats to current."""
|
"""Migrate old config formats to current."""
|
||||||
|
agents = data.get("agents", {})
|
||||||
|
defaults = agents.get("defaults", {}) if isinstance(agents, dict) else {}
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
had_legacy_max_messages = (
|
||||||
|
"maxMessages" in defaults or "max_messages" in defaults
|
||||||
|
)
|
||||||
|
defaults.pop("maxMessages", None)
|
||||||
|
defaults.pop("max_messages", None)
|
||||||
|
if had_legacy_max_messages:
|
||||||
|
# TODO(v0.3.1): Remove this legacy cleanup branch. v0.3.0 is the
|
||||||
|
# final release that warns before the schema silently ignores the field.
|
||||||
|
logger.warning(
|
||||||
|
"agents.defaults.maxMessages/max_messages is legacy and ignored; "
|
||||||
|
"replay max messages is now an internal safety cap. Remove it from "
|
||||||
|
"config. This compatibility warning will be removed in the next version."
|
||||||
|
)
|
||||||
|
|
||||||
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
# Move tools.exec.restrictToWorkspace → tools.restrictToWorkspace
|
||||||
tools = data.get("tools", {})
|
tools = data.get("tools", {})
|
||||||
exec_cfg = tools.get("exec", {})
|
exec_cfg = tools.get("exec", {})
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ class DreamConfig(Base):
|
|||||||
default=None,
|
default=None,
|
||||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||||
) # Override model for Dream sessions (pending implementation)
|
) # Override model for Dream sessions (pending implementation)
|
||||||
|
max_batch_size: int = Field(default=20, ge=1) # Deprecated: no longer used
|
||||||
|
max_iterations: int = Field(default=15, ge=1) # Deprecated: no longer used
|
||||||
|
annotate_line_ages: bool = True # Deprecated: no longer used
|
||||||
|
|
||||||
def build_schedule(self, timezone: str) -> CronSchedule:
|
def build_schedule(self, timezone: str) -> CronSchedule:
|
||||||
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
||||||
@@ -403,17 +406,11 @@ class ToolsConfig(Base):
|
|||||||
"webuiAllowRemotePackageInstall",
|
"webuiAllowRemotePackageInstall",
|
||||||
"webui_allow_remote_package_install",
|
"webui_allow_remote_package_install",
|
||||||
),
|
),
|
||||||
) # allow non-local WebUI clients to install optional support and extension packages
|
) # allow non-local WebUI clients to install optional Python packages
|
||||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||||
|
|
||||||
|
|
||||||
class ExtensionsConfig(Base):
|
|
||||||
"""Global switch for external extension activation."""
|
|
||||||
|
|
||||||
enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseSettings):
|
class Config(BaseSettings):
|
||||||
"""Root configuration for nanobot."""
|
"""Root configuration for nanobot."""
|
||||||
|
|
||||||
@@ -424,7 +421,6 @@ class Config(BaseSettings):
|
|||||||
api: ApiConfig = Field(default_factory=ApiConfig)
|
api: ApiConfig = Field(default_factory=ApiConfig)
|
||||||
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
gateway: GatewayConfig = Field(default_factory=GatewayConfig)
|
||||||
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
||||||
extensions: ExtensionsConfig = Field(default_factory=ExtensionsConfig)
|
|
||||||
model_presets: dict[str, ModelPresetConfig] = Field(
|
model_presets: dict[str, ModelPresetConfig] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
validation_alias=AliasChoices("modelPresets", "model_presets"),
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
"""Stable author-facing API for native nanobot extensions."""
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import (
|
|
||||||
EXTENSION_API_VERSION,
|
|
||||||
DependencyKind,
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.runtime import PythonExtensionApi
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"EXTENSION_API_VERSION",
|
|
||||||
"DependencyKind",
|
|
||||||
"ExtensionDependency",
|
|
||||||
"ExtensionManifest",
|
|
||||||
"ExtensionPermission",
|
|
||||||
"PythonExtensionApi",
|
|
||||||
]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
"""Discover installed extensions and resolve one activation snapshot."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from nanobot.extensions.preflight import evaluate_dependencies
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDiagnostic,
|
|
||||||
ExtensionRegistry,
|
|
||||||
ExtensionSnapshot,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.store import ExtensionStore
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionCatalog:
|
|
||||||
"""Discovered candidates plus the active, policy-resolved snapshot."""
|
|
||||||
|
|
||||||
candidates: tuple[ExtensionCandidate, ...]
|
|
||||||
snapshot: ExtensionSnapshot
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...]
|
|
||||||
|
|
||||||
|
|
||||||
def build_extension_catalog(
|
|
||||||
config: Config,
|
|
||||||
*,
|
|
||||||
user_root: Path | None = None,
|
|
||||||
) -> ExtensionCatalog:
|
|
||||||
"""Build the authoritative extension view without executing package code."""
|
|
||||||
if not config.extensions.enabled:
|
|
||||||
return ExtensionCatalog((), ExtensionSnapshot((), ()), ())
|
|
||||||
|
|
||||||
discovery = ExtensionStore(user_root).discover()
|
|
||||||
candidates, dependency_diagnostics = evaluate_dependencies(
|
|
||||||
discovery.candidates
|
|
||||||
)
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry_diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
try:
|
|
||||||
registry.register(candidate)
|
|
||||||
except ValueError as exc:
|
|
||||||
registry_diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="duplicate_installation",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=str(exc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
diagnostics = (
|
|
||||||
discovery.diagnostics
|
|
||||||
+ dependency_diagnostics
|
|
||||||
+ tuple(registry_diagnostics)
|
|
||||||
+ snapshot.diagnostics
|
|
||||||
)
|
|
||||||
return ExtensionCatalog(candidates, snapshot, diagnostics)
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
"""JSON persistence for extension manifests."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import ExtensionManifest
|
|
||||||
|
|
||||||
MANIFEST_FILENAME = "nanobot.extension.json"
|
|
||||||
|
|
||||||
|
|
||||||
class ManifestFormatError(ValueError):
|
|
||||||
"""Raised when a manifest cannot be decoded unambiguously."""
|
|
||||||
|
|
||||||
|
|
||||||
def load_manifest(path: Path) -> ExtensionManifest:
|
|
||||||
"""Read and validate one canonical JSON manifest."""
|
|
||||||
try:
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
||||||
raise ManifestFormatError(f"cannot read extension manifest {path}: {exc}") from exc
|
|
||||||
return manifest_from_mapping(data)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_manifest(manifest: ExtensionManifest, path: Path) -> None:
|
|
||||||
"""Write one canonical JSON manifest."""
|
|
||||||
path.write_text(
|
|
||||||
json.dumps(manifest_to_mapping(manifest), ensure_ascii=False, indent=2) + "\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_from_mapping(data: object) -> ExtensionManifest:
|
|
||||||
"""Decode a manifest and reject unknown or invalid fields."""
|
|
||||||
try:
|
|
||||||
return ExtensionManifest.model_validate(data)
|
|
||||||
except ValidationError as exc:
|
|
||||||
unknown = sorted(
|
|
||||||
".".join(str(part) for part in error["loc"])
|
|
||||||
for error in exc.errors()
|
|
||||||
if error["type"] == "extra_forbidden"
|
|
||||||
)
|
|
||||||
if unknown:
|
|
||||||
raise ManifestFormatError(
|
|
||||||
f"extension manifest has unknown fields: {', '.join(unknown)}"
|
|
||||||
) from exc
|
|
||||||
raise ManifestFormatError(f"invalid extension manifest: {exc}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def manifest_to_mapping(manifest: ExtensionManifest) -> dict[str, Any]:
|
|
||||||
"""Return the canonical JSON representation."""
|
|
||||||
return manifest.model_dump(mode="json", by_alias=True)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""Side-effect-free discovery of extension manifests on disk."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDiagnostic,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionDiscoveryResult:
|
|
||||||
candidates: tuple[ExtensionCandidate, ...] = ()
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
def discover_manifest_root(
|
|
||||||
root: Path,
|
|
||||||
) -> ExtensionDiscoveryResult:
|
|
||||||
"""Discover direct children containing ``nanobot.extension.json``."""
|
|
||||||
if not root.exists():
|
|
||||||
return ExtensionDiscoveryResult()
|
|
||||||
if not root.is_dir():
|
|
||||||
return ExtensionDiscoveryResult(
|
|
||||||
diagnostics=(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="invalid_extension_root",
|
|
||||||
extension_id="",
|
|
||||||
message=f"extension root is not a directory: {root}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
manifests = []
|
|
||||||
direct_manifest = root / MANIFEST_FILENAME
|
|
||||||
if direct_manifest.is_file():
|
|
||||||
manifests.append(direct_manifest)
|
|
||||||
manifests.extend(
|
|
||||||
sorted(
|
|
||||||
path / MANIFEST_FILENAME
|
|
||||||
for path in root.iterdir()
|
|
||||||
if not path.name.startswith(".")
|
|
||||||
and path.is_dir()
|
|
||||||
and (path / MANIFEST_FILENAME).is_file()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
candidates: list[ExtensionCandidate] = []
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for path in manifests:
|
|
||||||
try:
|
|
||||||
manifest = load_manifest(path)
|
|
||||||
candidates.append(
|
|
||||||
ExtensionCandidate(
|
|
||||||
manifest=manifest,
|
|
||||||
location=path.parent.resolve(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="invalid_manifest",
|
|
||||||
extension_id=path.parent.name,
|
|
||||||
message=str(exc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Agent-side lifecycle for first-class extensions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from nanobot.extensions.catalog import ExtensionCatalog, build_extension_catalog
|
|
||||||
from nanobot.extensions.registry import ExtensionDiagnostic
|
|
||||||
from nanobot.extensions.runtime import ActivationResult, ExtensionRuntimeManager
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nanobot.agent.loop import AgentLoop
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionHostSnapshot:
|
|
||||||
"""Current discovery and activation result."""
|
|
||||||
|
|
||||||
catalog: ExtensionCatalog
|
|
||||||
activation: ActivationResult
|
|
||||||
|
|
||||||
@property
|
|
||||||
def diagnostics(self) -> tuple[ExtensionDiagnostic, ...]:
|
|
||||||
return self.catalog.diagnostics + self.activation.diagnostics
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionHost:
|
|
||||||
"""Reload external extensions without coupling their lifecycle to AgentLoop."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
agent: AgentLoop,
|
|
||||||
config_loader: Callable[[], Config],
|
|
||||||
*,
|
|
||||||
user_root: Path | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._agent = agent
|
|
||||||
self._config_loader = config_loader
|
|
||||||
self._user_root = user_root
|
|
||||||
self._manager: ExtensionRuntimeManager | None = None
|
|
||||||
self._snapshot: ExtensionHostSnapshot | None = None
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def snapshot(self) -> ExtensionHostSnapshot | None:
|
|
||||||
return self._snapshot
|
|
||||||
|
|
||||||
async def reload(self) -> ExtensionHostSnapshot:
|
|
||||||
async with self._lock:
|
|
||||||
await self._close_manager()
|
|
||||||
self._snapshot = None
|
|
||||||
config = self._config_loader()
|
|
||||||
catalog = build_extension_catalog(
|
|
||||||
config,
|
|
||||||
user_root=self._user_root,
|
|
||||||
)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=self._agent.tools,
|
|
||||||
commands=self._agent.commands,
|
|
||||||
hook_factories=self._agent._hook_factories,
|
|
||||||
)
|
|
||||||
activation = await manager.activate(catalog.snapshot)
|
|
||||||
self._manager = manager
|
|
||||||
self._snapshot = ExtensionHostSnapshot(catalog, activation)
|
|
||||||
for diagnostic in self._snapshot.diagnostics:
|
|
||||||
logger.warning(
|
|
||||||
"Extension {} [{}]: {}",
|
|
||||||
diagnostic.extension_id,
|
|
||||||
diagnostic.code,
|
|
||||||
diagnostic.message,
|
|
||||||
)
|
|
||||||
return self._snapshot
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
async with self._lock:
|
|
||||||
await self._close_manager()
|
|
||||||
self._snapshot = None
|
|
||||||
|
|
||||||
async def _close_manager(self) -> None:
|
|
||||||
if self._manager is not None:
|
|
||||||
await self._manager.close()
|
|
||||||
self._manager = None
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
"""Strict schema for native nanobot extension packages."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Literal, Self
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
||||||
|
|
||||||
EXTENSION_API_VERSION = 1
|
|
||||||
|
|
||||||
_IDENTIFIER = re.compile(r"[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?")
|
|
||||||
_PERMISSION = re.compile(r"[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*")
|
|
||||||
|
|
||||||
|
|
||||||
class DependencyKind(str, Enum):
|
|
||||||
"""Kinds of prerequisites resolved before activation."""
|
|
||||||
|
|
||||||
PYTHON = "python"
|
|
||||||
EXECUTABLE = "executable"
|
|
||||||
ENVIRONMENT = "environment"
|
|
||||||
|
|
||||||
|
|
||||||
class _ManifestModel(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True, populate_by_name=True)
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionDependency(_ManifestModel):
|
|
||||||
"""One activation prerequisite declared by an extension."""
|
|
||||||
|
|
||||||
kind: DependencyKind
|
|
||||||
name: str
|
|
||||||
specifier: str = ""
|
|
||||||
optional: bool = False
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, value: str) -> str:
|
|
||||||
return _require_text(value, "extension dependency name")
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionPermission(_ManifestModel):
|
|
||||||
"""A privileged host capability requested by an extension."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
reason: str = ""
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, value: str) -> str:
|
|
||||||
if _PERMISSION.fullmatch(value) is None:
|
|
||||||
raise ValueError(
|
|
||||||
"extension permission must be a lowercase namespaced identifier"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionManifest(_ManifestModel):
|
|
||||||
"""Identity, prerequisites, and consent declarations for one extension."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
version: str
|
|
||||||
entry: str = "extension:register"
|
|
||||||
description: str = ""
|
|
||||||
dependencies: tuple[ExtensionDependency, ...] = ()
|
|
||||||
permissions: tuple[ExtensionPermission, ...] = ()
|
|
||||||
api_version: Literal[EXTENSION_API_VERSION] = Field(
|
|
||||||
default=EXTENSION_API_VERSION,
|
|
||||||
alias="apiVersion",
|
|
||||||
)
|
|
||||||
homepage: str = ""
|
|
||||||
license: str = ""
|
|
||||||
|
|
||||||
@field_validator("id")
|
|
||||||
@classmethod
|
|
||||||
def validate_id(cls, value: str) -> str:
|
|
||||||
return _require_identifier(value, "extension id")
|
|
||||||
|
|
||||||
@field_validator("name", "version")
|
|
||||||
@classmethod
|
|
||||||
def validate_required_text(cls, value: str, info) -> str:
|
|
||||||
return _require_text(value, f"extension {info.field_name}")
|
|
||||||
|
|
||||||
@field_validator("entry")
|
|
||||||
@classmethod
|
|
||||||
def validate_entry(cls, value: str) -> str:
|
|
||||||
value = _require_text(value, "extension entry")
|
|
||||||
module_name = value.partition(":")[0]
|
|
||||||
if Path(module_name).is_absolute() or ".." in Path(module_name).parts:
|
|
||||||
raise ValueError("extension entry cannot escape the package root")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def reject_duplicates(self) -> Self:
|
|
||||||
dependencies = [(item.kind, item.name) for item in self.dependencies]
|
|
||||||
if len(set(dependencies)) != len(dependencies):
|
|
||||||
raise ValueError("extension manifest contains duplicate dependencies")
|
|
||||||
permissions = [item.name for item in self.permissions]
|
|
||||||
if len(set(permissions)) != len(permissions):
|
|
||||||
raise ValueError("extension manifest contains duplicate permissions")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
def _require_text(value: str, label: str) -> str:
|
|
||||||
if not value.strip():
|
|
||||||
raise ValueError(f"{label} must be a non-empty string")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _require_identifier(value: str, label: str) -> str:
|
|
||||||
value = _require_text(value, label)
|
|
||||||
if _IDENTIFIER.fullmatch(value) is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"{label} must use lowercase letters, digits, dots, underscores, or hyphens"
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def validate_extension_id(value: object) -> str:
|
|
||||||
"""Validate and return one portable extension identifier."""
|
|
||||||
if not isinstance(value, str):
|
|
||||||
raise ValueError("extension id must be a string")
|
|
||||||
return _require_identifier(value, "extension id")
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Activation preflight for extension runtime prerequisites."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib.metadata
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
from dataclasses import replace
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import DependencyKind, ExtensionDependency
|
|
||||||
from nanobot.extensions.registry import ExtensionCandidate, ExtensionDiagnostic
|
|
||||||
from nanobot.extensions.versioning import dependency_version_failure
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate_dependencies(
|
|
||||||
candidates: tuple[ExtensionCandidate, ...],
|
|
||||||
) -> tuple[tuple[ExtensionCandidate, ...], tuple[ExtensionDiagnostic, ...]]:
|
|
||||||
"""Disable candidates with missing required software and explain why."""
|
|
||||||
checked: list[ExtensionCandidate] = []
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for candidate in candidates:
|
|
||||||
failures = [
|
|
||||||
message
|
|
||||||
for dependency in candidate.manifest.dependencies
|
|
||||||
if not dependency.optional
|
|
||||||
if (message := _dependency_failure(dependency))
|
|
||||||
]
|
|
||||||
if failures:
|
|
||||||
candidate = replace(candidate, enabled=False)
|
|
||||||
diagnostics.extend(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="dependency_missing",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
for message in failures
|
|
||||||
)
|
|
||||||
checked.append(candidate)
|
|
||||||
return tuple(checked), tuple(diagnostics)
|
|
||||||
|
|
||||||
|
|
||||||
def _dependency_failure(
|
|
||||||
dependency: ExtensionDependency,
|
|
||||||
) -> str:
|
|
||||||
if dependency.kind is DependencyKind.EXECUTABLE:
|
|
||||||
if shutil.which(dependency.name) is None:
|
|
||||||
return f"Required executable is not installed: {dependency.name}"
|
|
||||||
return ""
|
|
||||||
if dependency.kind is DependencyKind.ENVIRONMENT:
|
|
||||||
if not os.getenv(dependency.name):
|
|
||||||
return f"Required environment variable is not set: {dependency.name}"
|
|
||||||
return ""
|
|
||||||
if dependency.kind is DependencyKind.PYTHON:
|
|
||||||
try:
|
|
||||||
version = importlib.metadata.version(dependency.name)
|
|
||||||
except importlib.metadata.PackageNotFoundError:
|
|
||||||
return f"Required Python package is not installed: {dependency.name}"
|
|
||||||
return dependency_version_failure(dependency, version, "Python package")
|
|
||||||
return f"Unsupported dependency kind: {dependency.kind.value}"
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""Deterministic extension activation planning."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import ExtensionManifest
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionCandidate:
|
|
||||||
"""One discovered extension package and its activation state."""
|
|
||||||
|
|
||||||
manifest: ExtensionManifest
|
|
||||||
location: Path | None = None
|
|
||||||
enabled: bool = True
|
|
||||||
trusted: bool = False
|
|
||||||
integrity_valid: bool = True
|
|
||||||
granted_permissions: frozenset[str] = frozenset()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionDiagnostic:
|
|
||||||
"""A non-fatal discovery or activation problem."""
|
|
||||||
|
|
||||||
code: str
|
|
||||||
extension_id: str
|
|
||||||
message: str
|
|
||||||
severity: str = "warning"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ExtensionSnapshot:
|
|
||||||
"""Immutable activation plan consumed by the runtime."""
|
|
||||||
|
|
||||||
extensions: tuple[ExtensionCandidate, ...]
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionRegistry:
|
|
||||||
"""Select trusted candidates and report missing permission grants."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._candidates: dict[str, ExtensionCandidate] = {}
|
|
||||||
|
|
||||||
def register(self, candidate: ExtensionCandidate) -> None:
|
|
||||||
extension_id = candidate.manifest.id
|
|
||||||
if extension_id in self._candidates:
|
|
||||||
raise ValueError(f"extension '{extension_id}' is already installed")
|
|
||||||
self._candidates[extension_id] = candidate
|
|
||||||
|
|
||||||
def snapshot(self) -> ExtensionSnapshot:
|
|
||||||
active: list[ExtensionCandidate] = []
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for candidate in sorted(
|
|
||||||
self._candidates.values(),
|
|
||||||
key=lambda item: item.manifest.id,
|
|
||||||
):
|
|
||||||
requested = {
|
|
||||||
permission.name for permission in candidate.manifest.permissions
|
|
||||||
}
|
|
||||||
missing = sorted(requested - candidate.granted_permissions)
|
|
||||||
if (
|
|
||||||
candidate.enabled
|
|
||||||
and candidate.integrity_valid
|
|
||||||
and candidate.trusted
|
|
||||||
and not missing
|
|
||||||
):
|
|
||||||
active.append(candidate)
|
|
||||||
elif candidate.enabled and candidate.trusted and missing:
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="permission_required",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=(
|
|
||||||
"Grant required extension permissions: "
|
|
||||||
+ ", ".join(missing)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ExtensionSnapshot(tuple(active), tuple(diagnostics))
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
"""Transactional activation at nanobot's tool, command, and hook seams."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from importlib.machinery import ModuleSpec
|
|
||||||
from pathlib import Path
|
|
||||||
from types import ModuleType
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.agent.hook import AgentHook, AgentTurnHookFactory
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter, Handler
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDiagnostic,
|
|
||||||
ExtensionSnapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ActivationResult:
|
|
||||||
"""Immutable activation outcome consumed by the agent assembly layer."""
|
|
||||||
|
|
||||||
extensions: tuple[ExtensionCandidate, ...]
|
|
||||||
hook_factories: tuple[AgentTurnHookFactory, ...]
|
|
||||||
diagnostics: tuple[ExtensionDiagnostic, ...]
|
|
||||||
|
|
||||||
|
|
||||||
class PythonExtensionApi:
|
|
||||||
"""Small native API; extensions register into existing nanobot interfaces."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
owner: str,
|
|
||||||
tools: ToolRegistry,
|
|
||||||
commands: CommandRouter,
|
|
||||||
hook_factories: list[AgentTurnHookFactory],
|
|
||||||
) -> None:
|
|
||||||
self.owner = owner
|
|
||||||
self._tools = tools
|
|
||||||
self._commands = commands
|
|
||||||
self._hook_factories = hook_factories
|
|
||||||
|
|
||||||
def register_tool(self, tool: Tool) -> None:
|
|
||||||
if not self._tools.register_if_absent(tool, owner=self.owner):
|
|
||||||
existing = self._tools.owner(tool.name) or "unknown"
|
|
||||||
raise ValueError(
|
|
||||||
f"tool '{tool.name}' is already registered by '{existing}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_command(
|
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
handler: Handler,
|
|
||||||
*,
|
|
||||||
prefix: bool = False,
|
|
||||||
) -> None:
|
|
||||||
command = f"/{command.lstrip('/')}"
|
|
||||||
if prefix:
|
|
||||||
command = f"{command} "
|
|
||||||
register = self._commands.prefix if prefix else self._commands.exact
|
|
||||||
tier = "prefix" if prefix else "exact"
|
|
||||||
if existing := self._commands.owner(tier, command):
|
|
||||||
raise ValueError(
|
|
||||||
f"command '{command}' is already registered by '{existing}'"
|
|
||||||
)
|
|
||||||
register(command, handler, owner=self.owner)
|
|
||||||
|
|
||||||
def register_hook_factory(self, factory: AgentTurnHookFactory) -> None:
|
|
||||||
self._hook_factories.append(_owned_hook_factory(factory, self.owner))
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionRuntimeManager:
|
|
||||||
"""Activate a resolved snapshot and roll back failed registrations."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
tools: ToolRegistry,
|
|
||||||
commands: CommandRouter,
|
|
||||||
hook_factories: list[AgentTurnHookFactory] | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._tools = tools
|
|
||||||
self._commands = commands
|
|
||||||
self._active: list[ExtensionCandidate] = []
|
|
||||||
self._hook_factories = hook_factories if hook_factories is not None else []
|
|
||||||
|
|
||||||
async def activate(self, snapshot: ExtensionSnapshot) -> ActivationResult:
|
|
||||||
diagnostics: list[ExtensionDiagnostic] = []
|
|
||||||
for candidate in snapshot.extensions:
|
|
||||||
try:
|
|
||||||
active = self._activate_candidate(candidate)
|
|
||||||
self._active.append(active)
|
|
||||||
except Exception as exc:
|
|
||||||
self._rollback_owner(candidate.manifest.id)
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="activation_failed",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=str(exc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ActivationResult(
|
|
||||||
tuple(self._active),
|
|
||||||
tuple(self._hook_factories),
|
|
||||||
tuple(diagnostics),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
for active in reversed(self._active):
|
|
||||||
self._rollback_owner(active.manifest.id)
|
|
||||||
_unload_extension_modules(active)
|
|
||||||
self._active.clear()
|
|
||||||
|
|
||||||
def _activate_candidate(
|
|
||||||
self,
|
|
||||||
candidate: ExtensionCandidate,
|
|
||||||
) -> ExtensionCandidate:
|
|
||||||
self._activate_python(candidate)
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
def _activate_python(self, candidate: ExtensionCandidate) -> None:
|
|
||||||
raw_entry = candidate.manifest.entry
|
|
||||||
module_name, separator, attribute = raw_entry.partition(":")
|
|
||||||
if not separator:
|
|
||||||
module_name = raw_entry
|
|
||||||
attribute = "register"
|
|
||||||
assert candidate.location is not None
|
|
||||||
importlib.invalidate_caches()
|
|
||||||
module_prefix = _module_prefix(candidate.manifest.id)
|
|
||||||
_unload_extension_modules(candidate)
|
|
||||||
package = ModuleType(module_prefix)
|
|
||||||
package.__package__ = module_prefix
|
|
||||||
package.__path__ = [str(candidate.location)]
|
|
||||||
package.__spec__ = ModuleSpec(module_prefix, loader=None, is_package=True)
|
|
||||||
sys.modules[module_prefix] = package
|
|
||||||
try:
|
|
||||||
module = importlib.import_module(f"{module_prefix}.{module_name}")
|
|
||||||
module_path = getattr(module, "__file__", None)
|
|
||||||
if not module_path or not Path(module_path).resolve().is_relative_to(
|
|
||||||
candidate.location.resolve()
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
f"Python extension entry resolves outside its package: {module_name}"
|
|
||||||
)
|
|
||||||
register = getattr(module, attribute)
|
|
||||||
api = PythonExtensionApi(
|
|
||||||
owner=candidate.manifest.id,
|
|
||||||
tools=self._tools,
|
|
||||||
commands=self._commands,
|
|
||||||
hook_factories=self._hook_factories,
|
|
||||||
)
|
|
||||||
result = register(api)
|
|
||||||
if result is not None:
|
|
||||||
raise TypeError("Python extension register function must return None")
|
|
||||||
except Exception:
|
|
||||||
_unload_extension_modules(candidate)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _rollback_owner(
|
|
||||||
self,
|
|
||||||
owner: str,
|
|
||||||
) -> None:
|
|
||||||
self._tools.unregister_owner(owner)
|
|
||||||
self._commands.unregister_owner(owner)
|
|
||||||
self._hook_factories[:] = [
|
|
||||||
factory
|
|
||||||
for factory in self._hook_factories
|
|
||||||
if getattr(factory, "__nanobot_extension_owner__", None) != owner
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _owned_hook_factory(
|
|
||||||
factory: AgentTurnHookFactory,
|
|
||||||
owner: str,
|
|
||||||
) -> AgentTurnHookFactory:
|
|
||||||
def owned(context: Any) -> AgentHook | None:
|
|
||||||
return factory(context)
|
|
||||||
|
|
||||||
setattr(owned, "__nanobot_extension_owner__", owner)
|
|
||||||
return owned
|
|
||||||
|
|
||||||
|
|
||||||
def _modules_under(root: Path) -> tuple[str, ...]:
|
|
||||||
package_root = root.resolve()
|
|
||||||
return tuple(
|
|
||||||
name
|
|
||||||
for name, module in tuple(sys.modules.items())
|
|
||||||
if (raw_path := getattr(module, "__file__", None))
|
|
||||||
and Path(raw_path).resolve().is_relative_to(package_root)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _unload_modules_under(root: Path) -> None:
|
|
||||||
package_root = root.resolve()
|
|
||||||
for module_name in _modules_under(package_root):
|
|
||||||
sys.modules.pop(module_name, None)
|
|
||||||
for cache in package_root.rglob("__pycache__"):
|
|
||||||
shutil.rmtree(cache, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _module_prefix(extension_id: str) -> str:
|
|
||||||
return "_nanobot_extension_" + extension_id.encode().hex()
|
|
||||||
|
|
||||||
|
|
||||||
def _unload_extension_modules(candidate: ExtensionCandidate) -> None:
|
|
||||||
assert candidate.location is not None
|
|
||||||
prefix = _module_prefix(candidate.manifest.id)
|
|
||||||
for name in tuple(sys.modules):
|
|
||||||
if name == prefix or name.startswith(f"{prefix}."):
|
|
||||||
sys.modules.pop(name, None)
|
|
||||||
_unload_modules_under(candidate.location)
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"""Transport-neutral extension management service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from dataclasses import asdict
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.extensions.manifest import ExtensionManifest
|
|
||||||
from nanobot.extensions.registry import ExtensionCandidate
|
|
||||||
from nanobot.extensions.store import ExtensionStore, InstalledExtension
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionService:
|
|
||||||
"""One management boundary shared by CLI and WebUI."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
host: ExtensionHost | None = None,
|
|
||||||
store: ExtensionStore | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.host = host
|
|
||||||
self.store = store or ExtensionStore()
|
|
||||||
self._mutation_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
async def status(self) -> dict[str, Any]:
|
|
||||||
snapshot = self.host.snapshot if self.host else None
|
|
||||||
catalog = snapshot.catalog if snapshot else None
|
|
||||||
if catalog is None:
|
|
||||||
discovery = self.store.discover()
|
|
||||||
candidates = discovery.candidates
|
|
||||||
diagnostics = discovery.diagnostics
|
|
||||||
active_ids: set[str] = set()
|
|
||||||
else:
|
|
||||||
candidates = catalog.candidates
|
|
||||||
active_ids = {
|
|
||||||
active.manifest.id
|
|
||||||
for active in snapshot.activation.extensions
|
|
||||||
}
|
|
||||||
diagnostics = catalog.diagnostics + snapshot.activation.diagnostics
|
|
||||||
records = self.store.records()
|
|
||||||
return {
|
|
||||||
"extensions": [
|
|
||||||
_candidate_payload(candidate, active_ids, records.get(candidate.manifest.id))
|
|
||||||
for candidate in sorted(
|
|
||||||
candidates,
|
|
||||||
key=lambda item: item.manifest.name.lower(),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
"diagnostics": [asdict(item) for item in diagnostics],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def install(
|
|
||||||
self,
|
|
||||||
source: str,
|
|
||||||
*,
|
|
||||||
kind: str = "git",
|
|
||||||
ref: str = "",
|
|
||||||
trusted: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
async with self._mutation_lock:
|
|
||||||
if kind == "git":
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
self.store.install_git,
|
|
||||||
source,
|
|
||||||
ref=ref,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
elif kind == "local":
|
|
||||||
result = await asyncio.to_thread(
|
|
||||||
self.store.install_local,
|
|
||||||
Path(source),
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
raise ValueError(f"unknown extension source kind: {kind}")
|
|
||||||
await self._reload()
|
|
||||||
return {
|
|
||||||
"record": _record_payload(result.record),
|
|
||||||
"manifest": _manifest_payload(result.manifest),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def set_enabled(self, extension_id: str, enabled: bool) -> dict[str, Any]:
|
|
||||||
return await self._update(extension_id, self.store.set_enabled, enabled)
|
|
||||||
|
|
||||||
async def set_trusted(self, extension_id: str, trusted: bool) -> dict[str, Any]:
|
|
||||||
return await self._update(extension_id, self.store.set_trusted, trusted)
|
|
||||||
|
|
||||||
async def set_permissions(
|
|
||||||
self,
|
|
||||||
extension_id: str,
|
|
||||||
permissions: set[str] | frozenset[str],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return await self._update(
|
|
||||||
extension_id,
|
|
||||||
self.store.set_permissions,
|
|
||||||
permissions,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def uninstall(self, extension_id: str) -> dict[str, Any]:
|
|
||||||
async with self._mutation_lock:
|
|
||||||
await asyncio.to_thread(self.store.uninstall, extension_id)
|
|
||||||
await self._reload()
|
|
||||||
return {"removed": extension_id}
|
|
||||||
|
|
||||||
async def _update(self, extension_id: str, action: Any, value: Any) -> dict[str, Any]:
|
|
||||||
async with self._mutation_lock:
|
|
||||||
record = await asyncio.to_thread(action, extension_id, value)
|
|
||||||
await self._reload()
|
|
||||||
return {"record": _record_payload(record)}
|
|
||||||
|
|
||||||
async def _reload(self) -> None:
|
|
||||||
if self.host is not None:
|
|
||||||
await self.host.reload()
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate_payload(
|
|
||||||
candidate: ExtensionCandidate,
|
|
||||||
active_ids: set[str],
|
|
||||||
record: InstalledExtension | None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
manifest = candidate.manifest
|
|
||||||
requested = [permission.name for permission in manifest.permissions]
|
|
||||||
return {
|
|
||||||
**_manifest_payload(manifest),
|
|
||||||
"location": str(candidate.location) if candidate.location else None,
|
|
||||||
"enabled": candidate.enabled,
|
|
||||||
"trusted": candidate.trusted,
|
|
||||||
"active": manifest.id in active_ids,
|
|
||||||
"requested_permissions": requested,
|
|
||||||
"granted_permissions": sorted(candidate.granted_permissions),
|
|
||||||
"source": record.source.value if record else "path",
|
|
||||||
"source_ref": record.source_ref if record else "",
|
|
||||||
"integrity": record.integrity if record else "",
|
|
||||||
"installed_at": record.installed_at if record else "",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _manifest_payload(manifest: ExtensionManifest) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": manifest.id,
|
|
||||||
"name": manifest.name,
|
|
||||||
"version": manifest.version,
|
|
||||||
"description": manifest.description,
|
|
||||||
"homepage": manifest.homepage,
|
|
||||||
"license": manifest.license,
|
|
||||||
"dependencies": [
|
|
||||||
{
|
|
||||||
"kind": dependency.kind.value,
|
|
||||||
"name": dependency.name,
|
|
||||||
"specifier": dependency.specifier,
|
|
||||||
"optional": dependency.optional,
|
|
||||||
}
|
|
||||||
for dependency in manifest.dependencies
|
|
||||||
],
|
|
||||||
"permissions": [
|
|
||||||
{"name": permission.name, "reason": permission.reason}
|
|
||||||
for permission in manifest.permissions
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _record_payload(record: InstalledExtension) -> dict[str, Any]:
|
|
||||||
return record.model_dump(mode="json")
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
"""Atomic installation store and trust state for external extensions."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from dataclasses import dataclass, replace
|
|
||||||
from datetime import UTC, datetime
|
|
||||||
from enum import Enum
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from filelock import FileLock
|
|
||||||
from pydantic import BaseModel, ConfigDict, field_validator
|
|
||||||
|
|
||||||
from nanobot.extensions.codec import MANIFEST_FILENAME, load_manifest
|
|
||||||
from nanobot.extensions.discovery import (
|
|
||||||
ExtensionDiscoveryResult,
|
|
||||||
discover_manifest_root,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.manifest import ExtensionManifest, validate_extension_id
|
|
||||||
from nanobot.extensions.registry import ExtensionDiagnostic
|
|
||||||
|
|
||||||
_REGISTRY_FILENAME = ".registry.json"
|
|
||||||
_GIT_SCHEMES = frozenset({"git", "http", "https", "ssh"})
|
|
||||||
_SCP_GIT_URL = re.compile(
|
|
||||||
r"(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?:\S+"
|
|
||||||
)
|
|
||||||
_SHA256_INTEGRITY = re.compile(r"sha256:[0-9a-f]{64}")
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionSourceKind(str, Enum):
|
|
||||||
LOCAL = "local"
|
|
||||||
GIT = "git"
|
|
||||||
|
|
||||||
|
|
||||||
class InstalledExtension(BaseModel):
|
|
||||||
"""Persistent installation and policy record."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
||||||
|
|
||||||
id: str
|
|
||||||
version: str
|
|
||||||
source: ExtensionSourceKind
|
|
||||||
source_ref: str
|
|
||||||
integrity: str
|
|
||||||
installed_at: str
|
|
||||||
enabled: bool = True
|
|
||||||
trusted: bool = False
|
|
||||||
granted_permissions: tuple[str, ...] = ()
|
|
||||||
|
|
||||||
@field_validator("id")
|
|
||||||
@classmethod
|
|
||||||
def validate_id(cls, value: str) -> str:
|
|
||||||
return validate_extension_id(value)
|
|
||||||
|
|
||||||
@field_validator("version", "source_ref", "installed_at")
|
|
||||||
@classmethod
|
|
||||||
def validate_metadata(cls, value: str) -> str:
|
|
||||||
if not value:
|
|
||||||
raise ValueError("extension registry metadata must use non-empty strings")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("integrity")
|
|
||||||
@classmethod
|
|
||||||
def validate_integrity(cls, value: str) -> str:
|
|
||||||
if _SHA256_INTEGRITY.fullmatch(value) is None:
|
|
||||||
raise ValueError("extension registry integrity must be a sha256 digest")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("granted_permissions")
|
|
||||||
@classmethod
|
|
||||||
def reject_duplicate_permissions(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
||||||
if len(set(value)) != len(value):
|
|
||||||
raise ValueError("extension granted permissions cannot contain duplicates")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class InstallResult:
|
|
||||||
"""Installed package metadata."""
|
|
||||||
|
|
||||||
record: InstalledExtension
|
|
||||||
manifest: ExtensionManifest
|
|
||||||
|
|
||||||
|
|
||||||
class ExtensionStore:
|
|
||||||
"""Own the user extension directory and its atomic registry."""
|
|
||||||
|
|
||||||
def __init__(self, root: Path | None = None) -> None:
|
|
||||||
self.root = (root or Path.home() / ".nanobot" / "extensions").expanduser()
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.registry_path = self.root / _REGISTRY_FILENAME
|
|
||||||
self._lock = FileLock(str(self.root / ".lock"))
|
|
||||||
|
|
||||||
def records(self, *, strict: bool = False) -> dict[str, InstalledExtension]:
|
|
||||||
if not self.registry_path.is_file():
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
data = json.loads(self.registry_path.read_text(encoding="utf-8"))
|
|
||||||
if not isinstance(data, dict) or data.get("version") != 1:
|
|
||||||
raise ValueError("extension registry must be a version 1 object")
|
|
||||||
rows = data.get("extensions")
|
|
||||||
if not isinstance(rows, list):
|
|
||||||
raise ValueError("extension registry extensions must be an array")
|
|
||||||
records: dict[str, InstalledExtension] = {}
|
|
||||||
for item in rows:
|
|
||||||
record = InstalledExtension.model_validate(item)
|
|
||||||
if record.id in records:
|
|
||||||
raise ValueError(
|
|
||||||
f"extension registry contains duplicate id: {record.id}"
|
|
||||||
)
|
|
||||||
records[record.id] = record
|
|
||||||
return records
|
|
||||||
except (
|
|
||||||
OSError,
|
|
||||||
UnicodeError,
|
|
||||||
json.JSONDecodeError,
|
|
||||||
KeyError,
|
|
||||||
ValueError,
|
|
||||||
) as exc:
|
|
||||||
if strict:
|
|
||||||
raise ValueError(
|
|
||||||
f"invalid extension registry {self.registry_path}: {exc}"
|
|
||||||
) from exc
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def discover(self) -> ExtensionDiscoveryResult:
|
|
||||||
"""Discover packages and apply persisted enable/trust state."""
|
|
||||||
result = discover_manifest_root(self.root)
|
|
||||||
diagnostics = list(result.diagnostics)
|
|
||||||
try:
|
|
||||||
records = self.records(strict=True)
|
|
||||||
except ValueError as exc:
|
|
||||||
records = {}
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="invalid_extension_registry",
|
|
||||||
extension_id="",
|
|
||||||
message=str(exc),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
candidates = []
|
|
||||||
for candidate in result.candidates:
|
|
||||||
record = records.get(candidate.manifest.id)
|
|
||||||
trusted = record.trusted if record else False
|
|
||||||
integrity_valid = True
|
|
||||||
if candidate.location is not None and record is not None:
|
|
||||||
try:
|
|
||||||
_reject_unsafe_files(candidate.location)
|
|
||||||
actual_integrity = _tree_hash(candidate.location)
|
|
||||||
except (OSError, ValueError) as exc:
|
|
||||||
actual_integrity = ""
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="extension_integrity_error",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=f"Could not verify installed package: {exc}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if actual_integrity != record.integrity:
|
|
||||||
trusted = False
|
|
||||||
integrity_valid = False
|
|
||||||
diagnostics.append(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
code="extension_integrity_mismatch",
|
|
||||||
extension_id=candidate.manifest.id,
|
|
||||||
message=(
|
|
||||||
"Installed package contents changed after installation; "
|
|
||||||
"reinstall it before trusting it again"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
candidates.append(
|
|
||||||
replace(
|
|
||||||
candidate,
|
|
||||||
enabled=record.enabled if record else True,
|
|
||||||
trusted=trusted,
|
|
||||||
integrity_valid=integrity_valid,
|
|
||||||
granted_permissions=frozenset(
|
|
||||||
record.granted_permissions if record else ()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ExtensionDiscoveryResult(tuple(candidates), tuple(diagnostics))
|
|
||||||
|
|
||||||
def install_local(
|
|
||||||
self,
|
|
||||||
source: Path,
|
|
||||||
*,
|
|
||||||
trusted: bool = False,
|
|
||||||
) -> InstallResult:
|
|
||||||
return self._install_from_directory(
|
|
||||||
source.resolve(),
|
|
||||||
source_kind=ExtensionSourceKind.LOCAL,
|
|
||||||
source_ref=str(source.resolve()),
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
|
|
||||||
def install_git(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
*,
|
|
||||||
ref: str = "",
|
|
||||||
trusted: bool = False,
|
|
||||||
) -> InstallResult:
|
|
||||||
_validate_git_url(url)
|
|
||||||
with tempfile.TemporaryDirectory(prefix="nanobot-extension-git-") as raw:
|
|
||||||
checkout = Path(raw) / "checkout"
|
|
||||||
if ref:
|
|
||||||
_run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"clone",
|
|
||||||
"--filter=blob:none",
|
|
||||||
"--no-checkout",
|
|
||||||
"--",
|
|
||||||
url,
|
|
||||||
str(checkout),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
_run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"-C",
|
|
||||||
str(checkout),
|
|
||||||
"fetch",
|
|
||||||
"--depth",
|
|
||||||
"1",
|
|
||||||
"--",
|
|
||||||
"origin",
|
|
||||||
ref,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
_run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"-C",
|
|
||||||
str(checkout),
|
|
||||||
"checkout",
|
|
||||||
"--detach",
|
|
||||||
"FETCH_HEAD",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
_run(["git", "clone", "--depth", "1", "--", url, str(checkout)])
|
|
||||||
return self._install_from_directory(
|
|
||||||
checkout,
|
|
||||||
source_kind=ExtensionSourceKind.GIT,
|
|
||||||
source_ref=f"{url}#{ref}" if ref else url,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_enabled(self, extension_id: str, enabled: bool) -> InstalledExtension:
|
|
||||||
return self._update_record(extension_id, enabled=enabled)
|
|
||||||
|
|
||||||
def set_trusted(self, extension_id: str, trusted: bool) -> InstalledExtension:
|
|
||||||
return self._update_record(extension_id, trusted=trusted)
|
|
||||||
|
|
||||||
def set_permissions(
|
|
||||||
self,
|
|
||||||
extension_id: str,
|
|
||||||
permissions: set[str] | frozenset[str],
|
|
||||||
) -> InstalledExtension:
|
|
||||||
with self._lock:
|
|
||||||
records = self.records(strict=True)
|
|
||||||
if extension_id not in records:
|
|
||||||
raise KeyError(f"extension '{extension_id}' is not installed")
|
|
||||||
manifest = load_manifest(
|
|
||||||
self.root / extension_id / MANIFEST_FILENAME
|
|
||||||
)
|
|
||||||
requested = {
|
|
||||||
permission.name for permission in manifest.permissions
|
|
||||||
}
|
|
||||||
unknown = sorted(set(permissions) - requested)
|
|
||||||
if unknown:
|
|
||||||
raise ValueError(
|
|
||||||
"Cannot grant permissions not requested by the extension: "
|
|
||||||
+ ", ".join(unknown)
|
|
||||||
)
|
|
||||||
return self._update_record_locked(
|
|
||||||
records,
|
|
||||||
extension_id,
|
|
||||||
granted_permissions=tuple(sorted(permissions)),
|
|
||||||
)
|
|
||||||
|
|
||||||
def uninstall(self, extension_id: str) -> None:
|
|
||||||
with self._lock:
|
|
||||||
records = self.records(strict=True)
|
|
||||||
if extension_id not in records:
|
|
||||||
raise KeyError(f"extension '{extension_id}' is not installed")
|
|
||||||
target = self.root / extension_id
|
|
||||||
backup = self.root / f".uninstall-{uuid4().hex}"
|
|
||||||
if target.exists():
|
|
||||||
target.rename(backup)
|
|
||||||
try:
|
|
||||||
records.pop(extension_id)
|
|
||||||
self._write_records(records)
|
|
||||||
except Exception:
|
|
||||||
if backup.exists():
|
|
||||||
backup.rename(target)
|
|
||||||
raise
|
|
||||||
shutil.rmtree(backup, ignore_errors=True)
|
|
||||||
|
|
||||||
def _install_from_directory(
|
|
||||||
self,
|
|
||||||
source: Path,
|
|
||||||
*,
|
|
||||||
source_kind: ExtensionSourceKind,
|
|
||||||
source_ref: str,
|
|
||||||
trusted: bool,
|
|
||||||
) -> InstallResult:
|
|
||||||
with self._lock:
|
|
||||||
return self._install_from_directory_locked(
|
|
||||||
source,
|
|
||||||
source_kind=source_kind,
|
|
||||||
source_ref=source_ref,
|
|
||||||
trusted=trusted,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _install_from_directory_locked(
|
|
||||||
self,
|
|
||||||
source: Path,
|
|
||||||
*,
|
|
||||||
source_kind: ExtensionSourceKind,
|
|
||||||
source_ref: str,
|
|
||||||
trusted: bool,
|
|
||||||
) -> InstallResult:
|
|
||||||
if not source.is_dir():
|
|
||||||
raise ValueError(f"extension source is not a directory: {source}")
|
|
||||||
if self.root.resolve().is_relative_to(source.resolve()):
|
|
||||||
raise ValueError("extension source cannot contain the extension store")
|
|
||||||
_reject_unsafe_files(source)
|
|
||||||
manifest = load_manifest(source / MANIFEST_FILENAME)
|
|
||||||
extension_id = manifest.id
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
staging = self.root / f".install-{uuid4().hex}"
|
|
||||||
target = self.root / extension_id
|
|
||||||
backup = self.root / f".backup-{uuid4().hex}"
|
|
||||||
records = self.records(strict=True)
|
|
||||||
previous = records.get(extension_id)
|
|
||||||
backup_created = False
|
|
||||||
target_installed = False
|
|
||||||
try:
|
|
||||||
shutil.copytree(
|
|
||||||
source,
|
|
||||||
staging,
|
|
||||||
ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc"),
|
|
||||||
)
|
|
||||||
_reject_unsafe_files(staging)
|
|
||||||
integrity = _tree_hash(staging)
|
|
||||||
if target.exists():
|
|
||||||
target.rename(backup)
|
|
||||||
backup_created = True
|
|
||||||
staging.rename(target)
|
|
||||||
target_installed = True
|
|
||||||
requested_permissions = {
|
|
||||||
permission.name for permission in manifest.permissions
|
|
||||||
}
|
|
||||||
unchanged = bool(previous and previous.integrity == integrity)
|
|
||||||
record = InstalledExtension(
|
|
||||||
id=extension_id,
|
|
||||||
version=manifest.version,
|
|
||||||
source=source_kind,
|
|
||||||
source_ref=source_ref,
|
|
||||||
integrity=integrity,
|
|
||||||
installed_at=datetime.now(UTC).isoformat(),
|
|
||||||
enabled=previous.enabled if previous else True,
|
|
||||||
trusted=trusted or bool(unchanged and previous and previous.trusted),
|
|
||||||
granted_permissions=(
|
|
||||||
tuple(
|
|
||||||
permission
|
|
||||||
for permission in previous.granted_permissions
|
|
||||||
if permission in requested_permissions
|
|
||||||
)
|
|
||||||
if previous
|
|
||||||
else ()
|
|
||||||
),
|
|
||||||
)
|
|
||||||
records[extension_id] = record
|
|
||||||
self._write_records(records)
|
|
||||||
shutil.rmtree(backup, ignore_errors=True)
|
|
||||||
return InstallResult(record, manifest)
|
|
||||||
except Exception:
|
|
||||||
shutil.rmtree(staging, ignore_errors=True)
|
|
||||||
if target_installed:
|
|
||||||
shutil.rmtree(target, ignore_errors=True)
|
|
||||||
if backup_created:
|
|
||||||
backup.rename(target)
|
|
||||||
raise
|
|
||||||
|
|
||||||
def _update_record(
|
|
||||||
self,
|
|
||||||
extension_id: str,
|
|
||||||
**changes: Any,
|
|
||||||
) -> InstalledExtension:
|
|
||||||
with self._lock:
|
|
||||||
records = self.records(strict=True)
|
|
||||||
return self._update_record_locked(records, extension_id, **changes)
|
|
||||||
|
|
||||||
def _update_record_locked(
|
|
||||||
self,
|
|
||||||
records: dict[str, InstalledExtension],
|
|
||||||
extension_id: str,
|
|
||||||
**changes: Any,
|
|
||||||
) -> InstalledExtension:
|
|
||||||
try:
|
|
||||||
record = records[extension_id].model_copy(update=changes)
|
|
||||||
except KeyError as exc:
|
|
||||||
raise KeyError(
|
|
||||||
f"extension '{extension_id}' is not installed"
|
|
||||||
) from exc
|
|
||||||
records[extension_id] = record
|
|
||||||
self._write_records(records)
|
|
||||||
return record
|
|
||||||
|
|
||||||
def _write_records(self, records: dict[str, InstalledExtension]) -> None:
|
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
|
||||||
payload = {
|
|
||||||
"version": 1,
|
|
||||||
"extensions": [
|
|
||||||
record.model_dump(mode="json")
|
|
||||||
for record in sorted(records.values(), key=lambda item: item.id)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
temp = self.registry_path.with_suffix(".tmp")
|
|
||||||
temp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
||||||
os.replace(temp, self.registry_path)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(command: list[str], *, cwd: Path | None = None) -> str:
|
|
||||||
try:
|
|
||||||
return subprocess.run(
|
|
||||||
command,
|
|
||||||
cwd=cwd,
|
|
||||||
check=True,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
).stdout
|
|
||||||
except FileNotFoundError as exc:
|
|
||||||
raise RuntimeError(f"required executable not found: {command[0]}") from exc
|
|
||||||
except subprocess.CalledProcessError as exc:
|
|
||||||
detail = (exc.stderr or exc.stdout or "").strip()
|
|
||||||
raise RuntimeError(f"{command[0]} failed: {detail}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_git_url(url: str) -> None:
|
|
||||||
if not isinstance(url, str) or not url.strip() or any(
|
|
||||||
character in url for character in ("\0", "\r", "\n")
|
|
||||||
):
|
|
||||||
raise ValueError("extension Git source must be a remote repository URL")
|
|
||||||
value = url.strip()
|
|
||||||
parsed = urlparse(value)
|
|
||||||
if parsed.scheme:
|
|
||||||
if parsed.scheme.lower() not in _GIT_SCHEMES or not parsed.hostname or not parsed.path:
|
|
||||||
raise ValueError(
|
|
||||||
"extension Git source must use git, http, https, or ssh"
|
|
||||||
)
|
|
||||||
if parsed.password or (
|
|
||||||
parsed.scheme.lower() in {"http", "https"} and parsed.username
|
|
||||||
):
|
|
||||||
raise ValueError(
|
|
||||||
"extension Git URLs cannot contain credentials; use a Git credential helper"
|
|
||||||
)
|
|
||||||
if parsed.query or parsed.fragment:
|
|
||||||
raise ValueError(
|
|
||||||
"extension Git URLs cannot contain query parameters or fragments; "
|
|
||||||
"pass the revision separately"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if _SCP_GIT_URL.fullmatch(value) is None or any(
|
|
||||||
character in value for character in ("?", "#")
|
|
||||||
):
|
|
||||||
raise ValueError("extension Git source must be a remote repository URL")
|
|
||||||
|
|
||||||
|
|
||||||
def _reject_unsafe_files(root: Path) -> None:
|
|
||||||
for path in root.rglob("*"):
|
|
||||||
if path.is_symlink():
|
|
||||||
raise ValueError(f"extension packages cannot contain symlinks: {path}")
|
|
||||||
if not path.is_file() and not path.is_dir():
|
|
||||||
raise ValueError(f"extension package contains a special file: {path}")
|
|
||||||
|
|
||||||
|
|
||||||
def _tree_hash(root: Path) -> str:
|
|
||||||
digest = hashlib.sha256()
|
|
||||||
for path in sorted(
|
|
||||||
item
|
|
||||||
for item in root.rglob("*")
|
|
||||||
if (item.is_file() or item.is_symlink())
|
|
||||||
and "__pycache__" not in item.parts
|
|
||||||
and item.suffix not in {".pyc", ".pyo"}
|
|
||||||
):
|
|
||||||
digest.update(path.relative_to(root).as_posix().encode())
|
|
||||||
digest.update(b"\0")
|
|
||||||
if path.is_symlink():
|
|
||||||
digest.update(b"link\0")
|
|
||||||
digest.update(os.fsencode(os.readlink(path)))
|
|
||||||
continue
|
|
||||||
digest.update(b"file\0")
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
while chunk := handle.read(1024 * 1024):
|
|
||||||
digest.update(chunk)
|
|
||||||
return f"sha256:{digest.hexdigest()}"
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
"""Version constraint checks shared by extension activation gates."""
|
|
||||||
|
|
||||||
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
||||||
from packaging.version import InvalidVersion, Version
|
|
||||||
|
|
||||||
from nanobot.extensions.manifest import ExtensionDependency
|
|
||||||
|
|
||||||
|
|
||||||
def dependency_version_failure(
|
|
||||||
dependency: ExtensionDependency,
|
|
||||||
version: str,
|
|
||||||
label: str,
|
|
||||||
) -> str:
|
|
||||||
"""Return a user-facing constraint failure, or an empty string on success."""
|
|
||||||
if not dependency.specifier:
|
|
||||||
return ""
|
|
||||||
try:
|
|
||||||
matches = Version(version) in SpecifierSet(dependency.specifier)
|
|
||||||
except (InvalidSpecifier, InvalidVersion):
|
|
||||||
return (
|
|
||||||
f"{label} {dependency.name} has an unsupported version constraint: "
|
|
||||||
f"{dependency.specifier}"
|
|
||||||
)
|
|
||||||
if matches:
|
|
||||||
return ""
|
|
||||||
return (
|
|
||||||
f"{label} {dependency.name} {version} does not satisfy "
|
|
||||||
f"{dependency.specifier}"
|
|
||||||
)
|
|
||||||
+1
-20
@@ -11,7 +11,6 @@ from nanobot.agent.hook import AgentHook, SDKCaptureHook
|
|||||||
from nanobot.agent.hooks import create_file_edit_activity_hook
|
from nanobot.agent.hooks import create_file_edit_activity_hook
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||||
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
from nanobot.sdk.clients import MemoryClient, RuntimeClient, SessionClient
|
||||||
from nanobot.sdk.runtime import (
|
from nanobot.sdk.runtime import (
|
||||||
@@ -78,9 +77,6 @@ class Nanobot:
|
|||||||
self.sessions = SessionClient(loop)
|
self.sessions = SessionClient(loop)
|
||||||
self.memory = MemoryClient(loop)
|
self.memory = MemoryClient(loop)
|
||||||
self.runtime = RuntimeClient(loop)
|
self.runtime = RuntimeClient(loop)
|
||||||
self._extensions = ExtensionHost(loop, lambda: config) if config else None
|
|
||||||
self._extensions_started = False
|
|
||||||
self._extensions_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(
|
def from_config(
|
||||||
@@ -157,7 +153,6 @@ class Nanobot:
|
|||||||
model: Override the model for this run only.
|
model: Override the model for this run only.
|
||||||
model_preset: Override the model preset for this run only.
|
model_preset: Override the model preset for this run only.
|
||||||
"""
|
"""
|
||||||
await self._ensure_extensions()
|
|
||||||
capture = SDKCaptureHook()
|
capture = SDKCaptureHook()
|
||||||
per_run_hooks = [capture, *(hooks or [])]
|
per_run_hooks = [capture, *(hooks or [])]
|
||||||
runtime = self._loop.runtime_resolver.resolve_override(
|
runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
@@ -198,7 +193,6 @@ class Nanobot:
|
|||||||
model_preset: str | None = None,
|
model_preset: str | None = None,
|
||||||
) -> RunStream:
|
) -> RunStream:
|
||||||
"""Start a streamed run and return a handle for events and final result."""
|
"""Start a streamed run and return a handle for events and final result."""
|
||||||
await self._ensure_extensions()
|
|
||||||
override_runtime = self._loop.runtime_resolver.resolve_override(
|
override_runtime = self._loop.runtime_resolver.resolve_override(
|
||||||
model=model,
|
model=model,
|
||||||
model_preset=model_preset,
|
model_preset=model_preset,
|
||||||
@@ -322,20 +316,7 @@ class Nanobot:
|
|||||||
|
|
||||||
async def aclose(self) -> None:
|
async def aclose(self) -> None:
|
||||||
"""Release resources held by this instance (MCP connections, etc.)."""
|
"""Release resources held by this instance (MCP connections, etc.)."""
|
||||||
try:
|
await self._loop.close_mcp()
|
||||||
if self._extensions is not None:
|
|
||||||
await self._extensions.close()
|
|
||||||
self._extensions_started = False
|
|
||||||
finally:
|
|
||||||
await self._loop.close_mcp()
|
|
||||||
|
|
||||||
async def _ensure_extensions(self) -> None:
|
|
||||||
if self._extensions is None or self._extensions_started:
|
|
||||||
return
|
|
||||||
async with self._extensions_lock:
|
|
||||||
if not self._extensions_started:
|
|
||||||
await self._extensions.reload()
|
|
||||||
self._extensions_started = True
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Nanobot:
|
async def __aenter__(self) -> Nanobot:
|
||||||
return self
|
return self
|
||||||
|
|||||||
+66
-19
@@ -5,6 +5,7 @@ import errno
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
@@ -476,14 +477,6 @@ class SessionManager:
|
|||||||
except _SESSION_DATA_ERRORS:
|
except _SESSION_DATA_ERRORS:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _session_key_from_path(cls, path: Path) -> str | None:
|
|
||||||
"""Decode a session key only from a canonical collision-resistant filename."""
|
|
||||||
key = cls._decode_storage_key(path.stem)
|
|
||||||
if key is None or cls._storage_key(key) != path.stem:
|
|
||||||
return None
|
|
||||||
return key
|
|
||||||
|
|
||||||
def _get_session_path(self, key: str) -> Path:
|
def _get_session_path(self, key: str) -> Path:
|
||||||
"""Get the collision-resistant workspace path for a session."""
|
"""Get the collision-resistant workspace path for a session."""
|
||||||
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
return self.sessions_dir / f"{self._storage_key(key)}.jsonl"
|
||||||
@@ -496,6 +489,61 @@ class SessionManager:
|
|||||||
"""Legacy global session path (~/.nanobot/sessions/)."""
|
"""Legacy global session path (~/.nanobot/sessions/)."""
|
||||||
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
return self.legacy_sessions_dir / f"{self.safe_key(key)}.jsonl"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _stored_key_for_path(path: Path) -> str | None:
|
||||||
|
"""Read the stored session key from a JSONL metadata row, if present."""
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
data = json.loads(line)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("session records must be JSON objects")
|
||||||
|
if data.get("_type") == "metadata":
|
||||||
|
stored_key = data.get("key")
|
||||||
|
return stored_key if isinstance(stored_key, str) else None
|
||||||
|
return None
|
||||||
|
except _SESSION_DATA_ERRORS:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_session_path(self, key: str, *, migrate: bool = False) -> Path | None:
|
||||||
|
"""Resolve a session path, falling back to legacy storage locations."""
|
||||||
|
path = self._get_session_path(key)
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
|
||||||
|
# TODO(v0.3.1): Remove both legacy fallbacks. v0.3.0 is the final
|
||||||
|
# compatibility window for reading and lazily migrating legacy session files.
|
||||||
|
fallback_paths = [
|
||||||
|
(self._get_legacy_lossy_path(key), "legacy lossy path"),
|
||||||
|
(self._get_legacy_session_path(key), "legacy path"),
|
||||||
|
]
|
||||||
|
for fallback_path, description in fallback_paths:
|
||||||
|
if not fallback_path.exists():
|
||||||
|
continue
|
||||||
|
stored_key = self._stored_key_for_path(fallback_path)
|
||||||
|
if stored_key and stored_key != key:
|
||||||
|
logger.info(
|
||||||
|
"Skipping session {} from {} because it belongs to {}",
|
||||||
|
key,
|
||||||
|
description,
|
||||||
|
stored_key,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not migrate:
|
||||||
|
return fallback_path
|
||||||
|
try:
|
||||||
|
shutil.move(str(fallback_path), str(path))
|
||||||
|
logger.info("Migrated session {} from {}", key, description)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to migrate session {}", key)
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
def get_or_create(self, key: str) -> Session:
|
def get_or_create(self, key: str) -> Session:
|
||||||
"""
|
"""
|
||||||
Get an existing session or create a new one.
|
Get an existing session or create a new one.
|
||||||
@@ -519,8 +567,8 @@ class SessionManager:
|
|||||||
|
|
||||||
def _load(self, key: str) -> Session | None:
|
def _load(self, key: str) -> Session | None:
|
||||||
"""Load a session from disk."""
|
"""Load a session from disk."""
|
||||||
path = self._get_session_path(key)
|
path = self._resolve_session_path(key, migrate=True)
|
||||||
if not path.exists():
|
if path is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -799,8 +847,8 @@ class SessionManager:
|
|||||||
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
Returns ``{"key", "created_at", "updated_at", "metadata", "messages"}`` or
|
||||||
``None`` when the session file does not exist or fails to parse.
|
``None`` when the session file does not exist or fails to parse.
|
||||||
"""
|
"""
|
||||||
path = self._get_session_path(key)
|
path = self._resolve_session_path(key)
|
||||||
if not path.exists():
|
if path is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
messages: list[dict[str, Any]] = []
|
messages: list[dict[str, Any]] = []
|
||||||
@@ -842,8 +890,8 @@ class SessionManager:
|
|||||||
This is used by WebUI routes that need session-level metadata but not the
|
This is used by WebUI routes that need session-level metadata but not the
|
||||||
full conversation transcript.
|
full conversation transcript.
|
||||||
"""
|
"""
|
||||||
path = self._get_session_path(key)
|
path = self._resolve_session_path(key)
|
||||||
if not path.exists():
|
if path is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -887,9 +935,8 @@ class SessionManager:
|
|||||||
sessions = []
|
sessions = []
|
||||||
|
|
||||||
for path in self.sessions_dir.glob("*.jsonl"):
|
for path in self.sessions_dir.glob("*.jsonl"):
|
||||||
storage_key = self._session_key_from_path(path)
|
decoded = self._decode_storage_key(path.stem)
|
||||||
if storage_key is None:
|
fallback_key = decoded or path.stem.replace("_", ":", 1)
|
||||||
continue
|
|
||||||
try:
|
try:
|
||||||
# Read the metadata line and a small preview for session lists.
|
# Read the metadata line and a small preview for session lists.
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
@@ -899,7 +946,7 @@ class SessionManager:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("session records must be JSON objects")
|
raise ValueError("session records must be JSON objects")
|
||||||
if data.get("_type") == "metadata":
|
if data.get("_type") == "metadata":
|
||||||
key = data.get("key") or storage_key
|
key = data.get("key") or fallback_key
|
||||||
metadata = data.get("metadata", {})
|
metadata = data.get("metadata", {})
|
||||||
title = _metadata_title(metadata)
|
title = _metadata_title(metadata)
|
||||||
preview = ""
|
preview = ""
|
||||||
@@ -944,7 +991,7 @@ class SessionManager:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
continue
|
continue
|
||||||
except _SESSION_DATA_ERRORS:
|
except _SESSION_DATA_ERRORS:
|
||||||
repaired = self._repair(storage_key, path=path)
|
repaired = self._repair(fallback_key, path=path)
|
||||||
if repaired is not None:
|
if repaired is not None:
|
||||||
sessions.append(
|
sessions.append(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
"""Authenticated HTTP adapter for the extension management service."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
from websockets.http11 import Request as WsRequest
|
|
||||||
from websockets.http11 import Response
|
|
||||||
|
|
||||||
from nanobot.extensions.service import ExtensionService
|
|
||||||
from nanobot.webui.http_utils import is_local_browser_request
|
|
||||||
|
|
||||||
_VALUES_HEADER = "X-Nanobot-Extension-Values"
|
|
||||||
_VALUES_MAX_BYTES = 32 * 1024
|
|
||||||
_ACTION_PATHS = {
|
|
||||||
"/api/extensions/install": "install",
|
|
||||||
"/api/extensions/enable": "enable",
|
|
||||||
"/api/extensions/disable": "disable",
|
|
||||||
"/api/extensions/trust": "trust",
|
|
||||||
"/api/extensions/untrust": "untrust",
|
|
||||||
"/api/extensions/permissions": "permissions",
|
|
||||||
"/api/extensions/uninstall": "uninstall",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class WebUIExtensionsRouter:
|
|
||||||
"""Keep extension policy and installation outside WebSocket transport."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
service: ExtensionService | None,
|
|
||||||
check_api_token: Callable[[WsRequest], bool],
|
|
||||||
json_response: Callable[[dict[str, Any]], Response],
|
|
||||||
error_response: Callable[[int, str | None], Response],
|
|
||||||
allow_remote_package_install: bool = False,
|
|
||||||
logger: Any,
|
|
||||||
) -> None:
|
|
||||||
self._service = service
|
|
||||||
self._check_api_token = check_api_token
|
|
||||||
self._json_response = json_response
|
|
||||||
self._error_response = error_response
|
|
||||||
self._allow_remote_package_install = allow_remote_package_install
|
|
||||||
self._logger = logger
|
|
||||||
|
|
||||||
async def dispatch(
|
|
||||||
self,
|
|
||||||
connection: Any,
|
|
||||||
request: WsRequest,
|
|
||||||
path: str,
|
|
||||||
) -> Response | None:
|
|
||||||
if not path.startswith("/api/extensions"):
|
|
||||||
return None
|
|
||||||
if not self._check_api_token(request):
|
|
||||||
return self._error_response(401, "Unauthorized")
|
|
||||||
if self._service is None:
|
|
||||||
return self._error_response(503, "Extension service is not available")
|
|
||||||
try:
|
|
||||||
if path == "/api/extensions":
|
|
||||||
if _method(request) != "GET":
|
|
||||||
return self._error_response(405, "Method not allowed")
|
|
||||||
return self._json_response(await self._service.status())
|
|
||||||
action = _ACTION_PATHS.get(path)
|
|
||||||
if action is None:
|
|
||||||
return None
|
|
||||||
if _method(request) != "POST":
|
|
||||||
return self._error_response(405, "Method not allowed")
|
|
||||||
if not self._mutation_allowed(action, connection, request):
|
|
||||||
return self._error_response(
|
|
||||||
403,
|
|
||||||
"Extension changes require a local WebUI connection",
|
|
||||||
)
|
|
||||||
values = self._values(request)
|
|
||||||
if (
|
|
||||||
action == "install"
|
|
||||||
and str(values.get("kind") or "git") == "local"
|
|
||||||
and not is_local_browser_request(connection, request.headers)
|
|
||||||
):
|
|
||||||
return self._error_response(
|
|
||||||
403,
|
|
||||||
"Local extension paths require a local WebUI connection",
|
|
||||||
)
|
|
||||||
return self._json_response(await self._run_action(action, values))
|
|
||||||
except KeyError as exc:
|
|
||||||
return self._error_response(404, str(exc))
|
|
||||||
except ValueError as exc:
|
|
||||||
return self._error_response(400, str(exc))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
return self._error_response(502, str(exc))
|
|
||||||
except Exception:
|
|
||||||
self._logger.exception("extension management request failed")
|
|
||||||
return self._error_response(500, "Extension operation failed")
|
|
||||||
|
|
||||||
async def _run_action(self, action: str, values: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
assert self._service is not None
|
|
||||||
extension_id = str(values.get("id") or "").strip()
|
|
||||||
if action == "install":
|
|
||||||
source = str(values.get("source") or "").strip()
|
|
||||||
if not source:
|
|
||||||
raise ValueError("Missing extension source")
|
|
||||||
return await self._service.install(
|
|
||||||
source,
|
|
||||||
kind=str(values.get("kind") or "git"),
|
|
||||||
ref=str(values.get("ref") or ""),
|
|
||||||
trusted=False,
|
|
||||||
)
|
|
||||||
if not extension_id:
|
|
||||||
raise ValueError("Missing extension ID")
|
|
||||||
if action == "enable":
|
|
||||||
return await self._service.set_enabled(extension_id, True)
|
|
||||||
if action == "disable":
|
|
||||||
return await self._service.set_enabled(extension_id, False)
|
|
||||||
if action == "trust":
|
|
||||||
return await self._service.set_trusted(extension_id, True)
|
|
||||||
if action == "untrust":
|
|
||||||
return await self._service.set_trusted(extension_id, False)
|
|
||||||
if action == "permissions":
|
|
||||||
permissions = values.get("permissions", [])
|
|
||||||
if not isinstance(permissions, list) or not all(
|
|
||||||
isinstance(permission, str) for permission in permissions
|
|
||||||
):
|
|
||||||
raise ValueError("Extension permissions must be an array of strings")
|
|
||||||
return await self._service.set_permissions(extension_id, set(permissions))
|
|
||||||
return await self._service.uninstall(extension_id)
|
|
||||||
|
|
||||||
def _values(self, request: WsRequest) -> dict[str, Any]:
|
|
||||||
raw = request.headers.get(_VALUES_HEADER)
|
|
||||||
if not raw:
|
|
||||||
return {}
|
|
||||||
if len(raw.encode("utf-8")) > _VALUES_MAX_BYTES:
|
|
||||||
raise ValueError("Extension request is too large")
|
|
||||||
try:
|
|
||||||
value = json.loads(unquote(raw))
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise ValueError("Invalid extension request") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise ValueError("Extension request must be a JSON object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
def _mutation_allowed(
|
|
||||||
self,
|
|
||||||
action: str,
|
|
||||||
connection: Any,
|
|
||||||
request: WsRequest,
|
|
||||||
) -> bool:
|
|
||||||
return is_local_browser_request(connection, request.headers) or (
|
|
||||||
action == "install" and self._allow_remote_package_install
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _method(request: WsRequest) -> str:
|
|
||||||
return str(getattr(request, "method", "GET")).upper()
|
|
||||||
@@ -51,8 +51,6 @@ def build_gateway_services(
|
|||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
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,
|
||||||
extension_service: Any | None = None,
|
|
||||||
allow_remote_package_install: bool = False,
|
|
||||||
logger: Any = default_logger,
|
logger: Any = default_logger,
|
||||||
) -> GatewayServices:
|
) -> GatewayServices:
|
||||||
tokens = GatewayTokenStore()
|
tokens = GatewayTokenStore()
|
||||||
@@ -96,8 +94,6 @@ def build_gateway_services(
|
|||||||
local_trigger_pending_ids=local_trigger_pending_ids,
|
local_trigger_pending_ids=local_trigger_pending_ids,
|
||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
extension_service=extension_service,
|
|
||||||
allow_remote_package_install=allow_remote_package_install,
|
|
||||||
log=logger,
|
log=logger,
|
||||||
)
|
)
|
||||||
return GatewayServices(
|
return GatewayServices(
|
||||||
|
|||||||
@@ -54,11 +54,7 @@ def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, An
|
|||||||
for row in existing_rows or []
|
for row in existing_rows or []
|
||||||
if isinstance(row.get("file"), str)
|
if isinstance(row.get("file"), str)
|
||||||
}
|
}
|
||||||
paths = sorted(
|
paths = sorted(session_manager.sessions_dir.glob("*.jsonl"))
|
||||||
path
|
|
||||||
for path in session_manager.sessions_dir.glob("*.jsonl")
|
|
||||||
if SessionManager._session_key_from_path(path) is not None
|
|
||||||
)
|
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
changed = existing_rows is None
|
changed = existing_rows is None
|
||||||
|
|
||||||
@@ -272,9 +268,8 @@ def _indexed_row_for_session(session: Session, path: Path) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str, Any] | None:
|
||||||
storage_key = SessionManager._session_key_from_path(path)
|
storage_key = SessionManager._decode_storage_key(path.stem)
|
||||||
if storage_key is None:
|
fallback_key = storage_key or path.stem.replace("_", ":", 1)
|
||||||
return None
|
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
first_line = f.readline().strip()
|
first_line = f.readline().strip()
|
||||||
@@ -325,7 +320,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
|
fallback_time = datetime.fromtimestamp(signature["mtime_ns"] / 1e9).isoformat()
|
||||||
created_at_s = created_at_s or fallback_time
|
created_at_s = created_at_s or fallback_time
|
||||||
updated_at_s = updated_at_s or fallback_time
|
updated_at_s = updated_at_s or fallback_time
|
||||||
key = data.get("key") or storage_key
|
key = data.get("key") or fallback_key
|
||||||
activity_signature = _webui_activity_signature(key)
|
activity_signature = _webui_activity_signature(key)
|
||||||
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
activity_updated_at = _webui_activity_updated_at(activity_signature)
|
||||||
return {
|
return {
|
||||||
@@ -345,7 +340,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
**activity_signature,
|
**activity_signature,
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
repaired = session_manager._repair(storage_key)
|
repaired = session_manager._repair(fallback_key)
|
||||||
if repaired is None:
|
if repaired is None:
|
||||||
return None
|
return None
|
||||||
return _indexed_row_for_session(repaired, path)
|
return _indexed_row_for_session(repaired, path)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ def default_webui_sidebar_state() -> dict[str, Any]:
|
|||||||
"project_name_overrides": {},
|
"project_name_overrides": {},
|
||||||
"tags_by_key": {},
|
"tags_by_key": {},
|
||||||
"collapsed_groups": {},
|
"collapsed_groups": {},
|
||||||
|
"activity_seen_at_by_key": {},
|
||||||
"view": {
|
"view": {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -87,6 +88,19 @@ def _clean_bool_map(value: Any) -> dict[str, bool]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_activity_seen_at_by_key(value: Any) -> dict[str, str]:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for key, raw_timestamp in list(value.items())[:_MAX_MAP_ITEMS]:
|
||||||
|
cleaned_key = _clean_string(key)
|
||||||
|
cleaned_timestamp = _clean_string(raw_timestamp, max_len=64)
|
||||||
|
if cleaned_key is None or cleaned_timestamp is None:
|
||||||
|
continue
|
||||||
|
out[cleaned_key] = cleaned_timestamp
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _clean_title_overrides(value: Any) -> dict[str, str]:
|
def _clean_title_overrides(value: Any) -> dict[str, str]:
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return {}
|
return {}
|
||||||
@@ -142,6 +156,9 @@ def normalize_webui_sidebar_state(raw: Any) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
state["tags_by_key"] = _clean_tags_by_key(raw.get("tags_by_key"))
|
||||||
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
state["collapsed_groups"] = _clean_bool_map(raw.get("collapsed_groups"))
|
||||||
|
state["activity_seen_at_by_key"] = _clean_activity_seen_at_by_key(
|
||||||
|
raw.get("activity_seen_at_by_key")
|
||||||
|
)
|
||||||
state["view"] = _clean_view(raw.get("view"))
|
state["view"] = _clean_view(raw.get("view"))
|
||||||
updated_at = raw.get("updated_at")
|
updated_at = raw.get("updated_at")
|
||||||
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
state["updated_at"] = updated_at if isinstance(updated_at, str) else None
|
||||||
|
|||||||
@@ -170,8 +170,6 @@ class GatewayHTTPHandler:
|
|||||||
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
|
||||||
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,
|
||||||
extension_service: Any | None = None,
|
|
||||||
allow_remote_package_install: bool = False,
|
|
||||||
log: Any = logger,
|
log: Any = logger,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -192,7 +190,6 @@ class GatewayHTTPHandler:
|
|||||||
self._log = log
|
self._log = log
|
||||||
self._runtime_surface = runtime_surface
|
self._runtime_surface = runtime_surface
|
||||||
|
|
||||||
from nanobot.webui.extensions_routes import WebUIExtensionsRouter
|
|
||||||
from nanobot.webui.settings_api import runtime_capabilities as _rc
|
from nanobot.webui.settings_api import runtime_capabilities as _rc
|
||||||
from nanobot.webui.settings_routes import WebUISettingsRouter
|
from nanobot.webui.settings_routes import WebUISettingsRouter
|
||||||
|
|
||||||
@@ -209,14 +206,6 @@ class GatewayHTTPHandler:
|
|||||||
channel_feature_action=channel_feature_action,
|
channel_feature_action=channel_feature_action,
|
||||||
channel_runtime_status=channel_runtime_status,
|
channel_runtime_status=channel_runtime_status,
|
||||||
)
|
)
|
||||||
self.extensions_routes = WebUIExtensionsRouter(
|
|
||||||
service=extension_service,
|
|
||||||
check_api_token=self.check_api_token,
|
|
||||||
json_response=_http_json_response,
|
|
||||||
error_response=_http_error,
|
|
||||||
allow_remote_package_install=allow_remote_package_install,
|
|
||||||
logger=self._log,
|
|
||||||
)
|
|
||||||
|
|
||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
return self._runtime_surface == "native" or _is_localhost(connection)
|
||||||
@@ -258,9 +247,6 @@ class GatewayHTTPHandler:
|
|||||||
|
|
||||||
# Settings routes (delegated)
|
# Settings routes (delegated)
|
||||||
response = await self.settings_routes.dispatch(connection, request, got)
|
response = await self.settings_routes.dispatch(connection, request, got)
|
||||||
if response is not None:
|
|
||||||
return response
|
|
||||||
response = await self.extensions_routes.dispatch(connection, request, got)
|
|
||||||
if response is not None:
|
if response is not None:
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -353,14 +353,6 @@ class TestBuildSystemPrompt:
|
|||||||
|
|
||||||
|
|
||||||
class TestBuildMessages:
|
class TestBuildMessages:
|
||||||
def test_optional_arguments_are_keyword_only(self, tmp_path):
|
|
||||||
builder = _builder(tmp_path)
|
|
||||||
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
builder.build_system_prompt(["legacy-skill"])
|
|
||||||
with pytest.raises(TypeError):
|
|
||||||
builder.build_messages([], "hello", ["legacy-skill"])
|
|
||||||
|
|
||||||
def test_basic_empty_history(self, tmp_path):
|
def test_basic_empty_history(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages([], "hello")
|
messages = builder.build_messages([], "hello")
|
||||||
@@ -371,7 +363,7 @@ class TestBuildMessages:
|
|||||||
|
|
||||||
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
def test_runtime_context_is_not_injected_by_default(self, tmp_path):
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
messages = builder.build_messages([], "hello", channel="cli")
|
messages = builder.build_messages([], "hello", channel="cli", chat_id="direct")
|
||||||
user_msg = str(messages[-1]["content"])
|
user_msg = str(messages[-1]["content"])
|
||||||
assert user_msg == "hello"
|
assert user_msg == "hello"
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ def test_provider_context_appended_after_user_content(tmp_path) -> None:
|
|||||||
history=[],
|
history=[],
|
||||||
current_message="hello world",
|
current_message="hello world",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
runtime_context_blocks=[
|
runtime_context_blocks=[
|
||||||
RuntimeContextBlock(source="test", content="provider context"),
|
RuntimeContextBlock(source="test", content="provider context"),
|
||||||
],
|
],
|
||||||
@@ -321,7 +322,7 @@ def test_build_messages_passes_channel_to_system_prompt(tmp_path) -> None:
|
|||||||
|
|
||||||
messages = builder.build_messages(
|
messages = builder.build_messages(
|
||||||
history=[], current_message="hi",
|
history=[], current_message="hi",
|
||||||
channel="telegram",
|
channel="telegram", chat_id="123",
|
||||||
)
|
)
|
||||||
system = messages[0]["content"]
|
system = messages[0]["content"]
|
||||||
assert "Format Hint" in system
|
assert "Format Hint" in system
|
||||||
@@ -348,6 +349,7 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path
|
|||||||
history=[{"role": "assistant", "content": "previous result"}],
|
history=[{"role": "assistant", "content": "previous result"}],
|
||||||
current_message="subagent result",
|
current_message="subagent result",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
current_role="assistant",
|
current_role="assistant",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind
|
from nanobot.agent.loop import AgentLoop, TurnContext, TurnKind, TurnState
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ChannelsConfig
|
from nanobot.config.schema import ChannelsConfig
|
||||||
@@ -27,7 +27,7 @@ def _make_loop(tmp_path: Path, channels_config: ChannelsConfig | None = None) ->
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_restore_turn_extracts_documents_by_default(
|
async def test_state_restore_extracts_documents_by_default(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -52,13 +52,14 @@ async def test_restore_turn_extracts_documents_by_default(
|
|||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
session_key="cli:c",
|
session_key="cli:c",
|
||||||
|
state=TurnState.RESTORE,
|
||||||
turn_id="turn-1",
|
turn_id="turn-1",
|
||||||
runtime=loop.llm_runtime(),
|
runtime=loop.llm_runtime(),
|
||||||
kind=TurnKind.USER,
|
kind=TurnKind.USER,
|
||||||
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
||||||
)
|
)
|
||||||
|
|
||||||
await loop._restore_turn(ctx)
|
assert await loop._state_restore(ctx) == "ok"
|
||||||
|
|
||||||
assert calls == [("summarize", [str(doc_path)])]
|
assert calls == [("summarize", [str(doc_path)])]
|
||||||
assert "Quarterly revenue" in ctx.msg.content
|
assert "Quarterly revenue" in ctx.msg.content
|
||||||
@@ -66,7 +67,7 @@ async def test_restore_turn_extracts_documents_by_default(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_restore_turn_references_documents_when_extraction_disabled(
|
async def test_state_restore_references_documents_when_extraction_disabled(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -89,13 +90,14 @@ async def test_restore_turn_references_documents_when_extraction_disabled(
|
|||||||
ctx = TurnContext(
|
ctx = TurnContext(
|
||||||
msg=msg,
|
msg=msg,
|
||||||
session_key="cli:c",
|
session_key="cli:c",
|
||||||
|
state=TurnState.RESTORE,
|
||||||
turn_id="turn-1",
|
turn_id="turn-1",
|
||||||
runtime=loop.llm_runtime(),
|
runtime=loop.llm_runtime(),
|
||||||
kind=TurnKind.USER,
|
kind=TurnKind.USER,
|
||||||
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
delivery=loop.turn_delivery_factory.create(msg, "cli:c"),
|
||||||
)
|
)
|
||||||
|
|
||||||
await loop._restore_turn(ctx)
|
assert await loop._state_restore(ctx) == "ok"
|
||||||
|
|
||||||
assert "Quarterly revenue" not in ctx.msg.content
|
assert "Quarterly revenue" not in ctx.msg.content
|
||||||
assert f"[Attachment: {doc_path}]" in ctx.msg.content
|
assert f"[Attachment: {doc_path}]" in ctx.msg.content
|
||||||
|
|||||||
@@ -414,13 +414,13 @@ class TestEphemeralDirect:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
original_save = loop._persist_turn
|
original_save = loop._state_save
|
||||||
|
|
||||||
async def patched_save(ctx):
|
async def patched_save(ctx):
|
||||||
captured["ephemeral"] = ctx.ephemeral
|
captured["ephemeral"] = ctx.ephemeral
|
||||||
return await original_save(ctx)
|
return await original_save(ctx)
|
||||||
|
|
||||||
with patch.object(loop, "_persist_turn", side_effect=patched_save):
|
with patch.object(loop, "_state_save", side_effect=patched_save):
|
||||||
await loop.process_direct(
|
await loop.process_direct(
|
||||||
"test", session_key="dream:check", ephemeral=True,
|
"test", session_key="dream:check", ephemeral=True,
|
||||||
)
|
)
|
||||||
@@ -435,13 +435,13 @@ class TestEphemeralDirect:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
original_save = loop._persist_turn
|
original_save = loop._state_save
|
||||||
|
|
||||||
async def patched_save(ctx):
|
async def patched_save(ctx):
|
||||||
captured["ephemeral"] = ctx.ephemeral
|
captured["ephemeral"] = ctx.ephemeral
|
||||||
return await original_save(ctx)
|
return await original_save(ctx)
|
||||||
|
|
||||||
with patch.object(loop, "_persist_turn", side_effect=patched_save):
|
with patch.object(loop, "_state_save", side_effect=patched_save):
|
||||||
await loop.process_direct("test", session_key="cli:normal")
|
await loop.process_direct("test", session_key="cli:normal")
|
||||||
|
|
||||||
assert captured.get("ephemeral") is False
|
assert captured.get("ephemeral") is False
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import pytest
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop, TurnState
|
||||||
from nanobot.agent.tools.context import RequestContext, request_context
|
from nanobot.agent.tools.context import RequestContext, request_context
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.outbound_events import (
|
from nanobot.bus.outbound_events import (
|
||||||
@@ -451,6 +451,7 @@ def test_build_and_save_preserves_user_text_containing_goal_guidance_tag(tmp_pat
|
|||||||
[],
|
[],
|
||||||
user_text,
|
user_text,
|
||||||
channel="cli",
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
)
|
)
|
||||||
assert "_meta" not in messages[-1]
|
assert "_meta" not in messages[-1]
|
||||||
|
|
||||||
@@ -475,6 +476,7 @@ def test_build_and_save_preserves_multimodal_user_block_starting_with_runtime_ta
|
|||||||
user_text,
|
user_text,
|
||||||
media=[str(image)],
|
media=[str(image)],
|
||||||
channel="cli",
|
channel="cli",
|
||||||
|
chat_id="direct",
|
||||||
)
|
)
|
||||||
|
|
||||||
loop._save_turn(session, messages, skip=1)
|
loop._save_turn(session, messages, skip=1)
|
||||||
@@ -1099,7 +1101,7 @@ async def test_websocket_internal_continuation_keeps_single_visible_run(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path: Path) -> None:
|
async def test_process_message_uses_context_chat_id_for_runtime_prompt(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||||
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
loop.context.build_messages = MagicMock( # type: ignore[method-assign]
|
||||||
@@ -1133,11 +1135,12 @@ async def test_process_message_keeps_delivery_chat_for_thread_session(tmp_path:
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.chat_id == "thread-777"
|
assert result.chat_id == "thread-777"
|
||||||
|
assert loop.context.build_messages.call_args.kwargs["chat_id"] == "parent-456"
|
||||||
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
assert loop._run_agent_loop.call_args.kwargs["chat_id"] == "thread-777"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_message_uses_explicit_session_for_goal_context(
|
async def test_process_message_uses_explicit_session_metadata_for_goal_context(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
@@ -1182,10 +1185,10 @@ async def test_process_message_uses_explicit_session_for_goal_context(
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.content == "ok"
|
assert result.content == "ok"
|
||||||
kwargs = loop._run_agent_loop.call_args.kwargs
|
kwargs = loop.context.build_messages.call_args.kwargs
|
||||||
assert kwargs["session"] is system_session
|
assert kwargs["chat_id"] == "chat-with-goal"
|
||||||
assert kwargs["session_key"] == "system"
|
assert kwargs["session_metadata"] is system_session.metadata
|
||||||
assert GOAL_STATE_KEY not in kwargs["session"].metadata
|
assert GOAL_STATE_KEY not in kwargs["session_metadata"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1567,26 +1570,27 @@ async def test_system_subagent_followup_does_not_log_content(tmp_path: Path) ->
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Path) -> None:
|
async def test_system_subagent_followup_uses_common_turn_state_machine(tmp_path: Path) -> None:
|
||||||
loop = _make_full_loop(tmp_path)
|
loop = _make_full_loop(tmp_path)
|
||||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock( # type: ignore[method-assign]
|
||||||
return_value=False
|
return_value=False
|
||||||
)
|
)
|
||||||
visited: list[str] = []
|
visited: list[TurnState] = []
|
||||||
|
|
||||||
for name in (
|
for state in (
|
||||||
"_restore_turn",
|
TurnState.RESTORE,
|
||||||
"_compact_session",
|
TurnState.COMPACT,
|
||||||
"_dispatch_command",
|
TurnState.COMMAND,
|
||||||
"_build_turn",
|
TurnState.BUILD,
|
||||||
"_run_turn",
|
TurnState.RUN,
|
||||||
"_persist_turn",
|
TurnState.SAVE,
|
||||||
"_prepare_outbound",
|
TurnState.RESPOND,
|
||||||
):
|
):
|
||||||
|
name = f"_state_{state.name.lower()}"
|
||||||
original = getattr(loop, name)
|
original = getattr(loop, name)
|
||||||
|
|
||||||
async def record(ctx, *, _original=original, _name=name):
|
async def record(ctx, *, _original=original, _state=state):
|
||||||
visited.append(_name)
|
visited.append(_state)
|
||||||
return await _original(ctx)
|
return await _original(ctx)
|
||||||
|
|
||||||
setattr(loop, name, record)
|
setattr(loop, name, record)
|
||||||
@@ -1602,33 +1606,25 @@ async def test_system_subagent_followup_uses_common_turn_lifecycle(tmp_path: Pat
|
|||||||
|
|
||||||
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
loop._run_agent_loop = fake_run_agent_loop # type: ignore[method-assign]
|
||||||
|
|
||||||
logs: list[str] = []
|
await loop._process_message(
|
||||||
sink_id = logger.add(logs.append, level="DEBUG", format="{message}")
|
InboundMessage(
|
||||||
try:
|
channel="system",
|
||||||
await loop._process_message(
|
sender_id="subagent",
|
||||||
InboundMessage(
|
chat_id="cli:test",
|
||||||
channel="system",
|
content="subagent result",
|
||||||
sender_id="subagent",
|
metadata={"subagent_task_id": "sub-1"},
|
||||||
chat_id="cli:test",
|
|
||||||
content="subagent result",
|
|
||||||
metadata={"subagent_task_id": "sub-1"},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
finally:
|
)
|
||||||
logger.remove(sink_id)
|
|
||||||
|
|
||||||
assert visited == [
|
assert visited == [
|
||||||
"_restore_turn",
|
TurnState.RESTORE,
|
||||||
"_compact_session",
|
TurnState.COMPACT,
|
||||||
"_dispatch_command",
|
TurnState.COMMAND,
|
||||||
"_build_turn",
|
TurnState.BUILD,
|
||||||
"_run_turn",
|
TurnState.RUN,
|
||||||
"_persist_turn",
|
TurnState.SAVE,
|
||||||
"_prepare_outbound",
|
TurnState.RESPOND,
|
||||||
]
|
]
|
||||||
logged = "".join(logs)
|
|
||||||
for stage in ("restore", "compact", "command", "build", "run", "save", "respond"):
|
|
||||||
assert f"Stage {stage} completed in" in logged
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -1693,6 +1689,7 @@ def test_subagent_followup_uses_user_model_input_and_assistant_history(tmp_path:
|
|||||||
current_message="subagent result",
|
current_message="subagent result",
|
||||||
current_role="user",
|
current_role="user",
|
||||||
channel="cli",
|
channel="cli",
|
||||||
|
chat_id="merge",
|
||||||
)
|
)
|
||||||
|
|
||||||
non_system = [m for m in projected if m.get("role") != "system"]
|
non_system = [m for m in projected if m.get("role") != "system"]
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ async def test_process_message_captures_original_text_before_restore(
|
|||||||
seen.append((ctx.original_user_text, ctx.runtime))
|
seen.append((ctx.original_user_text, ctx.runtime))
|
||||||
raise RuntimeError("captured before restore")
|
raise RuntimeError("captured before restore")
|
||||||
|
|
||||||
loop._restore_turn = stop_after_capture # type: ignore[method-assign]
|
loop._state_restore = stop_after_capture # type: ignore[method-assign]
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="captured before restore"):
|
with pytest.raises(RuntimeError, match="captured before restore"):
|
||||||
await loop._process_message(
|
await loop._process_message(
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ async def test_tool_fails_after_retry_exhausted():
|
|||||||
|
|
||||||
assert "failed after retry" in output
|
assert "failed after retry" in output
|
||||||
assert "ClosedResourceError" in output
|
assert "ClosedResourceError" in output
|
||||||
assert is_tool_error_result(output)
|
assert is_tool_error_result(wrapper.name, output)
|
||||||
assert session.call_tool.call_count == 2
|
assert session.call_tool.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ def test_save_uses_new_path_not_lossy(tmp_path: Path, monkeypatch) -> None:
|
|||||||
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
|
assert lossy_path.read_text(encoding="utf-8") == stale_lossy
|
||||||
|
|
||||||
|
|
||||||
def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
def test_load_falls_back_to_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
||||||
sm = _manager(tmp_path, monkeypatch)
|
sm = _manager(tmp_path, monkeypatch)
|
||||||
key = "telegram:legacy:lossy"
|
key = "telegram:legacy:lossy"
|
||||||
lossy_path = sm._get_legacy_lossy_path(key)
|
lossy_path = sm._get_legacy_lossy_path(key)
|
||||||
@@ -73,23 +73,49 @@ def test_load_ignores_legacy_lossy_path(tmp_path: Path, monkeypatch) -> None:
|
|||||||
|
|
||||||
session = sm._load(key)
|
session = sm._load(key)
|
||||||
|
|
||||||
assert session is None
|
assert session is not None
|
||||||
assert lossy_path.exists()
|
assert session.metadata == {"source": "test"}
|
||||||
assert not sm._get_session_path(key).exists()
|
assert session.messages[0]["content"] == "loaded from lossy"
|
||||||
|
|
||||||
|
|
||||||
def test_load_ignores_legacy_global_path(tmp_path: Path, monkeypatch) -> None:
|
def test_load_migrates_lossy_to_new_path(tmp_path: Path, monkeypatch) -> None:
|
||||||
sm = _manager(tmp_path, monkeypatch)
|
sm = _manager(tmp_path, monkeypatch)
|
||||||
key = "telegram:legacy:global"
|
key = "telegram:migrate:lossy"
|
||||||
new_path = sm._get_session_path(key)
|
new_path = sm._get_session_path(key)
|
||||||
legacy_path = sm._get_legacy_session_path(key)
|
lossy_path = sm._get_legacy_lossy_path(key)
|
||||||
_write_session_file(legacy_path, key, "loaded from global")
|
_write_session_file(lossy_path, key, "migrate me")
|
||||||
|
|
||||||
session = sm._load(key)
|
session = sm._load(key)
|
||||||
|
|
||||||
assert session is None
|
assert session is not None
|
||||||
assert legacy_path.exists()
|
assert session.messages[0]["content"] == "migrate me"
|
||||||
assert not new_path.exists()
|
assert new_path.exists()
|
||||||
|
assert not lossy_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_does_not_migrate_lossy_path_for_different_stored_key(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
sm = _manager(tmp_path, monkeypatch)
|
||||||
|
first_key = "telegram:a_b"
|
||||||
|
second_key = "telegram:a:b"
|
||||||
|
lossy_path = sm._get_legacy_lossy_path(first_key)
|
||||||
|
assert lossy_path == sm._get_legacy_lossy_path(second_key)
|
||||||
|
_write_session_file(lossy_path, first_key, "belongs to first")
|
||||||
|
|
||||||
|
loaded_second = sm._load(second_key)
|
||||||
|
|
||||||
|
assert loaded_second is None
|
||||||
|
assert lossy_path.exists()
|
||||||
|
assert not sm._get_session_path(second_key).exists()
|
||||||
|
|
||||||
|
loaded_first = sm._load(first_key)
|
||||||
|
|
||||||
|
assert loaded_first is not None
|
||||||
|
assert loaded_first.messages[0]["content"] == "belongs to first"
|
||||||
|
assert sm._get_session_path(first_key).exists()
|
||||||
|
assert not lossy_path.exists()
|
||||||
|
|
||||||
|
|
||||||
def test_safe_key_is_lossy() -> None:
|
def test_safe_key_is_lossy() -> None:
|
||||||
|
|||||||
@@ -140,5 +140,5 @@ async def test_loader_entry_point_error_wrapper_preserves_tool_api(tmp_path):
|
|||||||
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
|
assert tool.to_schema() == {"name": "api_plugin", "custom": True}
|
||||||
|
|
||||||
result = await tool.execute(value="1")
|
result = await tool.execute(value="1")
|
||||||
assert is_tool_error_result(result) is True
|
assert is_tool_error_result("api_plugin", result) is True
|
||||||
assert str(result) == "Error: plugin failed"
|
assert str(result) == "Error: plugin failed"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ async def test_run_inline_returns_structured_error(tmp_path):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result == "subagent failed"
|
assert result == "subagent failed"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result("spawn", result)
|
||||||
assert manager._running_tasks == {}
|
assert manager._running_tasks == {}
|
||||||
assert manager._session_tasks == {}
|
assert manager._session_tasks == {}
|
||||||
|
|
||||||
|
|||||||
@@ -788,6 +788,35 @@ def test_discover_plugins_skips_names_outside_enabled_set():
|
|||||||
assert loaded == []
|
assert loaded == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_discover_plugins_warns_once_for_legacy_entry_points():
|
||||||
|
from nanobot.channels.registry import _warn_legacy_channel_entry_points, discover_plugins
|
||||||
|
|
||||||
|
legacy_entry_points = [SimpleNamespace(name="z-old"), SimpleNamespace(name="a-old")]
|
||||||
|
_warn_legacy_channel_entry_points.cache_clear()
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"nanobot.channels.registry.entry_points",
|
||||||
|
return_value=legacy_entry_points,
|
||||||
|
) as metadata_entry_points,
|
||||||
|
patch("nanobot.channels.registry._channel_package_names", return_value=[]),
|
||||||
|
patch("nanobot.channels.registry.logger.warning") as warning,
|
||||||
|
):
|
||||||
|
discover_plugins()
|
||||||
|
discover_plugins()
|
||||||
|
finally:
|
||||||
|
_warn_legacy_channel_entry_points.cache_clear()
|
||||||
|
|
||||||
|
metadata_entry_points.assert_called_once_with(group="nanobot.channels")
|
||||||
|
warning.assert_called_once_with(
|
||||||
|
"Legacy channel entry points were detected but will not be loaded: {}. "
|
||||||
|
"The '{}' entry-point group is no longer supported; use a built-in channel or "
|
||||||
|
"migrate it into nanobot/channels/<channel>/.",
|
||||||
|
"a-old, z-old",
|
||||||
|
"nanobot.channels",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_channel_manifest_rejects_invalid_dependency_metadata():
|
def test_channel_manifest_rejects_invalid_dependency_metadata():
|
||||||
with pytest.raises(TypeError, match="tuple of requirements"):
|
with pytest.raises(TypeError, match="tuple of requirements"):
|
||||||
ChannelPlugin(
|
ChannelPlugin(
|
||||||
|
|||||||
+13
-34
@@ -1437,17 +1437,6 @@ def test_make_provider_rejects_auto_dynamic_custom_prefix_without_api_base():
|
|||||||
make_provider(config)
|
make_provider(config)
|
||||||
|
|
||||||
|
|
||||||
class _FakeExtensionHost:
|
|
||||||
def __init__(self, *_args, **_kwargs) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def reload(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def mock_agent_runtime(tmp_path):
|
def mock_agent_runtime(tmp_path):
|
||||||
"""Mock agent command dependencies for focused CLI tests."""
|
"""Mock agent command dependencies for focused CLI tests."""
|
||||||
@@ -1461,7 +1450,6 @@ def mock_agent_runtime(tmp_path):
|
|||||||
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
|
||||||
patch("nanobot.bus.queue.MessageBus"), \
|
patch("nanobot.bus.queue.MessageBus"), \
|
||||||
patch("nanobot.cron.service.CronService"), \
|
patch("nanobot.cron.service.CronService"), \
|
||||||
patch("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost), \
|
|
||||||
patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
|
patch("nanobot.cli.commands.AgentLoop.from_config") as mock_from_config:
|
||||||
agent_loop = MagicMock()
|
agent_loop = MagicMock()
|
||||||
agent_loop.channels_config = None
|
agent_loop.channels_config = None
|
||||||
@@ -1550,7 +1538,6 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -1593,7 +1580,6 @@ def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Pa
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
@@ -1644,7 +1630,6 @@ def test_agent_workspace_override_does_not_migrate_legacy_cron(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
|
|
||||||
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None)
|
||||||
|
|
||||||
result = runner.invoke(
|
result = runner.invoke(
|
||||||
@@ -1701,7 +1686,6 @@ def test_agent_custom_config_workspace_does_not_migrate_legacy_cron(
|
|||||||
|
|
||||||
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron)
|
||||||
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
|
||||||
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
"nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None
|
||||||
)
|
)
|
||||||
@@ -1744,6 +1728,17 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime,
|
|||||||
assert passed_config.workspace_path == workspace_path
|
assert passed_config.workspace_path == workspace_path
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_hints_about_deprecated_memory_window(mock_agent_runtime, tmp_path):
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text(json.dumps({"agents": {"defaults": {"memoryWindow": 42}}}))
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "memoryWindow" in result.stdout
|
||||||
|
assert "no longer used" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_heartbeat_retains_recent_messages_by_default():
|
def test_heartbeat_retains_recent_messages_by_default():
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|
||||||
@@ -1877,7 +1872,6 @@ def _patch_cli_command_runtime(
|
|||||||
) -> None:
|
) -> None:
|
||||||
provider_factory = make_provider or (lambda _config: _fake_provider())
|
provider_factory = make_provider or (lambda _config: _fake_provider())
|
||||||
|
|
||||||
monkeypatch.setattr("nanobot.extensions.host.ExtensionHost", _FakeExtensionHost)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"nanobot.config.loader.set_config_path",
|
"nanobot.config.loader.set_config_path",
|
||||||
set_config_path or (lambda _path: None),
|
set_config_path or (lambda _path: None),
|
||||||
@@ -2460,10 +2454,10 @@ def _patch_serve_runtime(monkeypatch, config: Config, seen: dict[str, object]) -
|
|||||||
seen["workspace"] = kwargs["workspace"]
|
seen["workspace"] = kwargs["workspace"]
|
||||||
|
|
||||||
async def _connect_mcp(self) -> None:
|
async def _connect_mcp(self) -> None:
|
||||||
seen["mcp_connected"] = True
|
return None
|
||||||
|
|
||||||
async def close_mcp(self) -> None:
|
async def close_mcp(self) -> None:
|
||||||
seen["mcp_closed"] = True
|
return None
|
||||||
|
|
||||||
def _fake_create_app(
|
def _fake_create_app(
|
||||||
agent_loop,
|
agent_loop,
|
||||||
@@ -3631,21 +3625,6 @@ def test_serve_uses_api_config_defaults_and_workspace_override(
|
|||||||
assert seen["api_key"] == "secret"
|
assert seen["api_key"] == "secret"
|
||||||
|
|
||||||
|
|
||||||
def test_serve_preserves_mcp_lifecycle(monkeypatch, tmp_path: Path) -> None:
|
|
||||||
config_file = _write_instance_config(tmp_path)
|
|
||||||
seen: dict[str, object] = {}
|
|
||||||
_patch_serve_runtime(monkeypatch, Config(), seen)
|
|
||||||
|
|
||||||
result = runner.invoke(app, ["serve", "--config", str(config_file)])
|
|
||||||
api_app = seen["api_app"]
|
|
||||||
asyncio.run(api_app.on_startup[0](api_app))
|
|
||||||
asyncio.run(api_app.on_cleanup[0](api_app))
|
|
||||||
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert seen["mcp_connected"] is True
|
|
||||||
assert seen["mcp_closed"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_trigger_cli_queues_message_in_workspace(
|
def test_trigger_cli_queues_message_in_workspace(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -1,105 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from rich.console import Console
|
|
||||||
from typer.testing import CliRunner
|
|
||||||
|
|
||||||
from nanobot.cli.extensions import create_extensions_app
|
|
||||||
|
|
||||||
|
|
||||||
class _Service:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[str, object]] = []
|
|
||||||
|
|
||||||
async def status(self):
|
|
||||||
self.calls.append(("status", None))
|
|
||||||
return {
|
|
||||||
"extensions": [
|
|
||||||
{
|
|
||||||
"id": "sample",
|
|
||||||
"name": "Sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Example extension",
|
|
||||||
"enabled": True,
|
|
||||||
"trusted": False,
|
|
||||||
"active": False,
|
|
||||||
"dependencies": [],
|
|
||||||
"permissions": [{"name": "network", "reason": "Fetch data"}],
|
|
||||||
"granted_permissions": [],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"diagnostics": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
async def install(self, source, *, kind, ref, trusted):
|
|
||||||
self.calls.append(("install", (source, kind, ref, trusted)))
|
|
||||||
return {
|
|
||||||
"record": {
|
|
||||||
"id": "sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"trusted": trusted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async def set_enabled(self, extension_id, enabled):
|
|
||||||
self.calls.append(("enabled", (extension_id, enabled)))
|
|
||||||
return {"record": {"id": extension_id}}
|
|
||||||
|
|
||||||
async def set_trusted(self, extension_id, trusted):
|
|
||||||
self.calls.append(("trusted", (extension_id, trusted)))
|
|
||||||
return {"record": {"id": extension_id}}
|
|
||||||
|
|
||||||
async def set_permissions(self, extension_id, permissions):
|
|
||||||
self.calls.append(("permissions", (extension_id, permissions)))
|
|
||||||
return {
|
|
||||||
"record": {
|
|
||||||
"id": extension_id,
|
|
||||||
"granted_permissions": sorted(permissions),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async def uninstall(self, extension_id):
|
|
||||||
self.calls.append(("uninstall", extension_id))
|
|
||||||
return {"removed": extension_id}
|
|
||||||
|
|
||||||
|
|
||||||
def _runner(service: _Service):
|
|
||||||
output = StringIO()
|
|
||||||
app = create_extensions_app(
|
|
||||||
console=Console(file=output, force_terminal=False),
|
|
||||||
service_factory=lambda: service,
|
|
||||||
)
|
|
||||||
return CliRunner(), app, output
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_cli_inspects() -> None:
|
|
||||||
service = _Service()
|
|
||||||
runner, app, output = _runner(service)
|
|
||||||
|
|
||||||
inspected = runner.invoke(app, ["inspect", "sample"])
|
|
||||||
assert inspected.exit_code == 0
|
|
||||||
assert "Sample" in output.getvalue()
|
|
||||||
assert service.calls == [("status", None)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_extension_cli_install_and_policy_commands() -> None:
|
|
||||||
service = _Service()
|
|
||||||
runner, app, _output = _runner(service)
|
|
||||||
|
|
||||||
assert runner.invoke(app, ["install", "https://example.com/acme.git"]).exit_code == 0
|
|
||||||
assert runner.invoke(app, ["trust", "sample"]).exit_code == 0
|
|
||||||
assert runner.invoke(app, ["disable", "sample"]).exit_code == 0
|
|
||||||
assert runner.invoke(
|
|
||||||
app,
|
|
||||||
["permissions", "sample", "network", "filesystem.read"],
|
|
||||||
).exit_code == 0
|
|
||||||
assert runner.invoke(app, ["uninstall", "sample", "--yes"]).exit_code == 0
|
|
||||||
|
|
||||||
assert service.calls == [
|
|
||||||
("install", ("https://example.com/acme.git", "git", "", False)),
|
|
||||||
("trusted", ("sample", True)),
|
|
||||||
("enabled", ("sample", False)),
|
|
||||||
("permissions", ("sample", {"network", "filesystem.read"})),
|
|
||||||
("uninstall", "sample"),
|
|
||||||
]
|
|
||||||
@@ -96,17 +96,22 @@ def test_onboard_does_not_crash_with_legacy_memory_window(tmp_path, monkeypatch)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
|
@pytest.mark.parametrize("field_name", ["maxMessages", "max_messages"])
|
||||||
def test_load_config_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
def test_load_config_warns_and_ignores_legacy_max_messages(tmp_path, field_name) -> None:
|
||||||
config_path = tmp_path / "config.json"
|
config_path = tmp_path / "config.json"
|
||||||
config_path.write_text(
|
config_path.write_text(
|
||||||
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
|
json.dumps({"agents": {"defaults": {field_name: 25, "maxTokens": 1234}}}),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
with patch("nanobot.config.loader.logger.warning") as warning:
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
assert config.agents.defaults.max_tokens == 1234
|
assert config.agents.defaults.max_tokens == 1234
|
||||||
assert not hasattr(config.agents.defaults, "max_messages")
|
assert not hasattr(config.agents.defaults, "max_messages")
|
||||||
|
warning.assert_called_once()
|
||||||
|
message = warning.call_args.args[0]
|
||||||
|
assert "legacy and ignored" in message
|
||||||
|
assert "next version" in message
|
||||||
|
|
||||||
|
|
||||||
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
||||||
@@ -116,7 +121,8 @@ def test_save_config_drops_legacy_max_messages(tmp_path) -> None:
|
|||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = load_config(config_path)
|
with patch("nanobot.config.loader.logger.warning"):
|
||||||
|
config = load_config(config_path)
|
||||||
save_config(config, config_path)
|
save_config(config, config_path)
|
||||||
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
saved = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|||||||
@@ -52,22 +52,3 @@ def test_dream_config_uses_model_override_name_and_accepts_legacy_model() -> Non
|
|||||||
assert cfg.model_override == "openrouter/sonnet"
|
assert cfg.model_override == "openrouter/sonnet"
|
||||||
assert dumped["modelOverride"] == "openrouter/sonnet"
|
assert dumped["modelOverride"] == "openrouter/sonnet"
|
||||||
assert "model" not in dumped
|
assert "model" not in dumped
|
||||||
|
|
||||||
|
|
||||||
def test_dream_config_ignores_retired_noop_fields() -> None:
|
|
||||||
cfg = DreamConfig.model_validate(
|
|
||||||
{
|
|
||||||
"maxBatchSize": 99,
|
|
||||||
"maxIterations": 99,
|
|
||||||
"annotateLineAges": False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
dumped = cfg.model_dump(by_alias=True)
|
|
||||||
|
|
||||||
assert not hasattr(cfg, "max_batch_size")
|
|
||||||
assert not hasattr(cfg, "max_iterations")
|
|
||||||
assert not hasattr(cfg, "annotate_line_ages")
|
|
||||||
assert "maxBatchSize" not in dumped
|
|
||||||
assert "maxIterations" not in dumped
|
|
||||||
assert "annotateLineAges" not in dumped
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
from nanobot.config.schema import Config
|
|
||||||
|
|
||||||
|
|
||||||
def test_extensions_can_be_disabled_globally() -> None:
|
|
||||||
config = Config.model_validate({"extensions": {"enabled": False}})
|
|
||||||
|
|
||||||
assert config.extensions.enabled is False
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
"""Extension platform tests."""
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.extensions.catalog import build_extension_catalog
|
|
||||||
from nanobot.extensions.store import ExtensionStore
|
|
||||||
|
|
||||||
|
|
||||||
def _write_extension(root, extension_id: str) -> None:
|
|
||||||
package = root / extension_id
|
|
||||||
package.mkdir(parents=True)
|
|
||||||
(package / "nanobot.extension.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"id": extension_id,
|
|
||||||
"name": extension_id,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"entry": "extension:register",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_catalog_requires_trust_before_activation(tmp_path) -> None:
|
|
||||||
_write_extension(tmp_path, "acme")
|
|
||||||
|
|
||||||
catalog = build_extension_catalog(Config(), user_root=tmp_path)
|
|
||||||
|
|
||||||
assert [item.manifest.id for item in catalog.candidates] == ["acme"]
|
|
||||||
assert catalog.snapshot.extensions == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_store_policy_can_trust_an_extension(tmp_path) -> None:
|
|
||||||
source = tmp_path / "source"
|
|
||||||
_write_extension(source, "acme")
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(source / "acme")
|
|
||||||
store.set_trusted("acme", True)
|
|
||||||
|
|
||||||
catalog = build_extension_catalog(Config(), user_root=store.root)
|
|
||||||
|
|
||||||
assert [item.manifest.id for item in catalog.snapshot.extensions] == ["acme"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_disabled_catalog_discovers_nothing(tmp_path) -> None:
|
|
||||||
_write_extension(tmp_path, "acme")
|
|
||||||
|
|
||||||
catalog = build_extension_catalog(
|
|
||||||
Config.model_validate({"extensions": {"enabled": False}}),
|
|
||||||
user_root=tmp_path,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert catalog.candidates == ()
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions import ExtensionManifest
|
|
||||||
from nanobot.extensions.codec import (
|
|
||||||
ManifestFormatError,
|
|
||||||
dump_manifest,
|
|
||||||
load_manifest,
|
|
||||||
manifest_from_mapping,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_json_round_trip(tmp_path) -> None:
|
|
||||||
path = tmp_path / "nanobot.extension.json"
|
|
||||||
original = ExtensionManifest(
|
|
||||||
id="acme.tools",
|
|
||||||
name="Acme Tools",
|
|
||||||
version="2.0.0",
|
|
||||||
entry="acme_extension:register",
|
|
||||||
)
|
|
||||||
|
|
||||||
dump_manifest(original, path)
|
|
||||||
|
|
||||||
assert load_manifest(path) == original
|
|
||||||
assert json.loads(path.read_text())["apiVersion"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_codec_rejects_unknown_fields() -> None:
|
|
||||||
with pytest.raises(ManifestFormatError, match="unknown fields: typo"):
|
|
||||||
manifest_from_mapping(
|
|
||||||
{"id": "bad", "name": "Bad", "version": "1.0.0", "typo": True}
|
|
||||||
)
|
|
||||||
with pytest.raises(ManifestFormatError, match="unknown fields: contributions"):
|
|
||||||
manifest_from_mapping(
|
|
||||||
{
|
|
||||||
"id": "bad",
|
|
||||||
"name": "Bad",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"contributions": [{"kind": "mystery", "name": "unknown"}],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from nanobot.extensions.discovery import discover_manifest_root
|
|
||||||
|
|
||||||
|
|
||||||
def _write_manifest(root, name: str, extension_id: str) -> None:
|
|
||||||
package = root / name
|
|
||||||
package.mkdir()
|
|
||||||
(package / "nanobot.extension.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"id": extension_id,
|
|
||||||
"name": extension_id,
|
|
||||||
"version": "1.0.0",
|
|
||||||
"entry": "extension:register",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_reads_metadata_without_importing_runtime(tmp_path) -> None:
|
|
||||||
_write_manifest(tmp_path, "one", "one")
|
|
||||||
_write_manifest(tmp_path, "two", "two")
|
|
||||||
|
|
||||||
result = discover_manifest_root(tmp_path)
|
|
||||||
|
|
||||||
assert [candidate.manifest.id for candidate in result.candidates] == ["one", "two"]
|
|
||||||
assert result.diagnostics == ()
|
|
||||||
|
|
||||||
|
|
||||||
def test_discovery_reports_bad_manifest_without_hiding_good_packages(tmp_path) -> None:
|
|
||||||
_write_manifest(tmp_path, "good", "good")
|
|
||||||
bad = tmp_path / "bad"
|
|
||||||
bad.mkdir()
|
|
||||||
(bad / "nanobot.extension.json").write_text("{")
|
|
||||||
|
|
||||||
result = discover_manifest_root(tmp_path)
|
|
||||||
|
|
||||||
assert [candidate.manifest.id for candidate in result.candidates] == ["good"]
|
|
||||||
assert result.diagnostics[0].code == "invalid_manifest"
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter
|
|
||||||
from nanobot.config.schema import Config
|
|
||||||
from nanobot.extensions.host import ExtensionHost
|
|
||||||
from nanobot.extensions.runtime import ExtensionRuntimeManager
|
|
||||||
|
|
||||||
|
|
||||||
class _Agent:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.tools = ToolRegistry()
|
|
||||||
self.commands = CommandRouter()
|
|
||||||
self._hook_factories = []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_host_reloads_and_closes_runtime(
|
|
||||||
monkeypatch,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
agent = _Agent()
|
|
||||||
config = Config()
|
|
||||||
activated: list[object] = []
|
|
||||||
closed: list[object] = []
|
|
||||||
|
|
||||||
async def activate(self, snapshot):
|
|
||||||
activated.append(snapshot)
|
|
||||||
return type(
|
|
||||||
"Result",
|
|
||||||
(),
|
|
||||||
{"extensions": (), "hook_factories": (), "diagnostics": ()},
|
|
||||||
)()
|
|
||||||
|
|
||||||
async def close(self):
|
|
||||||
closed.append(self)
|
|
||||||
|
|
||||||
monkeypatch.setattr(ExtensionRuntimeManager, "activate", activate)
|
|
||||||
monkeypatch.setattr(ExtensionRuntimeManager, "close", close)
|
|
||||||
|
|
||||||
host = ExtensionHost(agent, lambda: config, user_root=tmp_path)
|
|
||||||
first = await host.reload()
|
|
||||||
second = await host.reload()
|
|
||||||
await host.close()
|
|
||||||
|
|
||||||
assert host.snapshot is None
|
|
||||||
assert first.catalog.snapshot.extensions == ()
|
|
||||||
assert second.catalog.snapshot.extensions == ()
|
|
||||||
assert len(activated) == 2
|
|
||||||
assert len(closed) == 2
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions import (
|
|
||||||
ExtensionDependency,
|
|
||||||
ExtensionManifest,
|
|
||||||
ExtensionPermission,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.manifest import DependencyKind
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_defaults_to_native_python_entry() -> None:
|
|
||||||
manifest = ExtensionManifest(
|
|
||||||
id="acme.research",
|
|
||||||
name="Acme Research",
|
|
||||||
version="1.2.0",
|
|
||||||
permissions=(
|
|
||||||
ExtensionPermission(
|
|
||||||
name="network",
|
|
||||||
reason="Fetch user-selected sources.",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert manifest.entry == "extension:register"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("extension_id", ["Uppercase", "../escape", "two words", ""])
|
|
||||||
def test_manifest_rejects_invalid_ids(extension_id: str) -> None:
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
ExtensionManifest(id=extension_id, name="Invalid", version="1.0.0")
|
|
||||||
|
|
||||||
|
|
||||||
def test_manifest_rejects_duplicate_contract_rows() -> None:
|
|
||||||
dependency = ExtensionDependency(
|
|
||||||
kind=DependencyKind.PYTHON,
|
|
||||||
name="httpx",
|
|
||||||
)
|
|
||||||
permission = ExtensionPermission(name="network")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="duplicate dependencies"):
|
|
||||||
ExtensionManifest(
|
|
||||||
id="duplicate",
|
|
||||||
name="Duplicate",
|
|
||||||
version="1.0.0",
|
|
||||||
dependencies=(dependency, dependency),
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError, match="duplicate permissions"):
|
|
||||||
ExtensionManifest(
|
|
||||||
id="duplicate",
|
|
||||||
name="Duplicate",
|
|
||||||
version="1.0.0",
|
|
||||||
permissions=(permission, permission),
|
|
||||||
)
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
from nanobot.extensions import ExtensionDependency, ExtensionManifest
|
|
||||||
from nanobot.extensions.manifest import DependencyKind
|
|
||||||
from nanobot.extensions.preflight import evaluate_dependencies
|
|
||||||
from nanobot.extensions.registry import ExtensionCandidate
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate(dependency: ExtensionDependency) -> ExtensionCandidate:
|
|
||||||
return ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id="preflight.test",
|
|
||||||
name="Preflight test",
|
|
||||||
version="1.0.0",
|
|
||||||
dependencies=(dependency,),
|
|
||||||
),
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_missing_environment_dependency_disables_extension(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
|
||||||
|
|
||||||
candidates, diagnostics = evaluate_dependencies(
|
|
||||||
(
|
|
||||||
_candidate(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.ENVIRONMENT,
|
|
||||||
name="NANOBOT_EXTENSION_TEST_KEY",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert not candidates[0].enabled
|
|
||||||
assert diagnostics[0].code == "dependency_missing"
|
|
||||||
|
|
||||||
|
|
||||||
def test_optional_dependency_does_not_disable_extension(monkeypatch) -> None:
|
|
||||||
monkeypatch.delenv("NANOBOT_EXTENSION_TEST_KEY", raising=False)
|
|
||||||
|
|
||||||
candidates, diagnostics = evaluate_dependencies(
|
|
||||||
(
|
|
||||||
_candidate(
|
|
||||||
ExtensionDependency(
|
|
||||||
kind=DependencyKind.ENVIRONMENT,
|
|
||||||
name="NANOBOT_EXTENSION_TEST_KEY",
|
|
||||||
optional=True,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert candidates[0].enabled
|
|
||||||
assert diagnostics == ()
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions import ExtensionManifest, ExtensionPermission
|
|
||||||
from nanobot.extensions.registry import ExtensionCandidate, ExtensionRegistry
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate(
|
|
||||||
extension_id: str,
|
|
||||||
*,
|
|
||||||
trusted: bool = True,
|
|
||||||
permissions: tuple[ExtensionPermission, ...] = (),
|
|
||||||
granted: frozenset[str] = frozenset(),
|
|
||||||
) -> ExtensionCandidate:
|
|
||||||
return ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id=extension_id,
|
|
||||||
name=extension_id,
|
|
||||||
version="1.0.0",
|
|
||||||
permissions=permissions,
|
|
||||||
),
|
|
||||||
trusted=trusted,
|
|
||||||
granted_permissions=granted,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_only_trusted_extensions_activate() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(_candidate("trusted"))
|
|
||||||
registry.register(_candidate("untrusted", trusted=False))
|
|
||||||
|
|
||||||
assert [item.manifest.id for item in registry.snapshot().extensions] == ["trusted"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_every_requested_permission_must_be_granted() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(
|
|
||||||
_candidate(
|
|
||||||
"permission.test",
|
|
||||||
permissions=(
|
|
||||||
ExtensionPermission(name="network"),
|
|
||||||
ExtensionPermission(name="filesystem.read"),
|
|
||||||
),
|
|
||||||
granted=frozenset({"network"}),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
snapshot = registry.snapshot()
|
|
||||||
|
|
||||||
assert snapshot.extensions == ()
|
|
||||||
assert snapshot.diagnostics[0].code == "permission_required"
|
|
||||||
assert "filesystem.read" in snapshot.diagnostics[0].message
|
|
||||||
|
|
||||||
|
|
||||||
def test_duplicate_extension_ids_are_rejected() -> None:
|
|
||||||
registry = ExtensionRegistry()
|
|
||||||
registry.register(_candidate("duplicate"))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="already installed"):
|
|
||||||
registry.register(_candidate("duplicate"))
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
|
||||||
from nanobot.command.router import CommandRouter
|
|
||||||
from nanobot.extensions import ExtensionManifest
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionSnapshot,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.runtime import ExtensionRuntimeManager
|
|
||||||
|
|
||||||
|
|
||||||
def _candidate(root: Path, extension_id: str = "test.python") -> ExtensionCandidate:
|
|
||||||
return ExtensionCandidate(
|
|
||||||
ExtensionManifest(
|
|
||||||
id=extension_id,
|
|
||||||
name="Python extension",
|
|
||||||
version="1.0.0",
|
|
||||||
entry="extension:register",
|
|
||||||
),
|
|
||||||
location=root,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _snapshot(*candidates: ExtensionCandidate) -> ExtensionSnapshot:
|
|
||||||
return ExtensionSnapshot(candidates, ())
|
|
||||||
|
|
||||||
|
|
||||||
def _write_extension(root: Path, result: str = "ok") -> None:
|
|
||||||
(root / "extension.py").write_text(
|
|
||||||
f"""
|
|
||||||
from nanobot.agent.hook import AgentHook
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
|
|
||||||
class ExtensionTool(Tool):
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return "extension_echo"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def description(self):
|
|
||||||
return "Echo from an extension"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def parameters(self):
|
|
||||||
return {{"type": "object", "properties": {{}}}}
|
|
||||||
|
|
||||||
async def execute(self):
|
|
||||||
return "{result}"
|
|
||||||
|
|
||||||
async def command(_context):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def hook_factory(_context):
|
|
||||||
return AgentHook()
|
|
||||||
|
|
||||||
def register(api):
|
|
||||||
api.register_tool(ExtensionTool())
|
|
||||||
api.register_command("extension", command)
|
|
||||||
api.register_hook_factory(hook_factory)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def test_python_extension_registers_and_removes_owned_capabilities(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
_write_extension(tmp_path)
|
|
||||||
tools = ToolRegistry()
|
|
||||||
commands = CommandRouter()
|
|
||||||
hooks = []
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=tools,
|
|
||||||
commands=commands,
|
|
||||||
hook_factories=hooks,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(_candidate(tmp_path)))
|
|
||||||
|
|
||||||
assert result.diagnostics == ()
|
|
||||||
assert tools.owner("extension_echo") == "test.python"
|
|
||||||
assert commands.owner("exact", "/extension") == "test.python"
|
|
||||||
assert len(hooks) == 1
|
|
||||||
assert await tools.get("extension_echo").execute() == "ok"
|
|
||||||
|
|
||||||
await manager.close()
|
|
||||||
|
|
||||||
assert tools.owner("extension_echo") is None
|
|
||||||
assert commands.owner("exact", "/extension") is None
|
|
||||||
assert hooks == []
|
|
||||||
|
|
||||||
|
|
||||||
async def test_failed_registration_rolls_back_partial_state(tmp_path: Path) -> None:
|
|
||||||
(tmp_path / "extension.py").write_text(
|
|
||||||
"""
|
|
||||||
from nanobot.agent.tools.base import Tool
|
|
||||||
|
|
||||||
class DuplicateTool(Tool):
|
|
||||||
name = "duplicate"
|
|
||||||
description = "Duplicate"
|
|
||||||
parameters = {"type": "object", "properties": {}}
|
|
||||||
async def execute(self):
|
|
||||||
return "ok"
|
|
||||||
|
|
||||||
async def command(_context):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def register(api):
|
|
||||||
api.register_tool(DuplicateTool())
|
|
||||||
api.register_command("duplicate", command)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
tools = ToolRegistry()
|
|
||||||
commands = CommandRouter()
|
|
||||||
|
|
||||||
async def core_handler(_context):
|
|
||||||
return None
|
|
||||||
|
|
||||||
commands.exact("/duplicate", core_handler)
|
|
||||||
manager = ExtensionRuntimeManager(
|
|
||||||
tools=tools,
|
|
||||||
commands=commands,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(_snapshot(_candidate(tmp_path)))
|
|
||||||
|
|
||||||
assert result.extensions == ()
|
|
||||||
assert result.diagnostics[0].code == "activation_failed"
|
|
||||||
assert tools.get("duplicate") is None
|
|
||||||
assert commands.owner("exact", "/duplicate") == "nanobot.core"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_python_extension_reloads_updated_source(tmp_path: Path) -> None:
|
|
||||||
tools = ToolRegistry()
|
|
||||||
candidate = _candidate(tmp_path)
|
|
||||||
_write_extension(tmp_path, "first")
|
|
||||||
first = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
|
||||||
|
|
||||||
await first.activate(_snapshot(candidate))
|
|
||||||
assert await tools.get("extension_echo").execute() == "first"
|
|
||||||
await first.close()
|
|
||||||
|
|
||||||
_write_extension(tmp_path, "later")
|
|
||||||
second = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
|
||||||
await second.activate(_snapshot(candidate))
|
|
||||||
|
|
||||||
assert await tools.get("extension_echo").execute() == "later"
|
|
||||||
await second.close()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_extensions_with_the_same_entry_module_are_isolated(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
tools = ToolRegistry()
|
|
||||||
manager = ExtensionRuntimeManager(tools=tools, commands=CommandRouter())
|
|
||||||
first_root = tmp_path / "first"
|
|
||||||
second_root = tmp_path / "second"
|
|
||||||
first_root.mkdir()
|
|
||||||
second_root.mkdir()
|
|
||||||
_write_extension(first_root, "first")
|
|
||||||
_write_extension(second_root, "second")
|
|
||||||
second_source = (second_root / "extension.py").read_text()
|
|
||||||
(second_root / "extension.py").write_text(
|
|
||||||
second_source
|
|
||||||
.replace("extension_echo", "second_echo")
|
|
||||||
.replace('register_command("extension"', 'register_command("second"')
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await manager.activate(
|
|
||||||
_snapshot(
|
|
||||||
_candidate(first_root, "test.first"),
|
|
||||||
_candidate(second_root, "test.second"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.diagnostics == ()
|
|
||||||
assert await tools.get("extension_echo").execute() == "first"
|
|
||||||
assert await tools.get("second_echo").execute() == "second"
|
|
||||||
await manager.close()
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from nanobot.extensions import ExtensionManifest
|
|
||||||
from nanobot.extensions.registry import (
|
|
||||||
ExtensionCandidate,
|
|
||||||
ExtensionDiagnostic,
|
|
||||||
ExtensionSnapshot,
|
|
||||||
)
|
|
||||||
from nanobot.extensions.service import ExtensionService
|
|
||||||
from nanobot.extensions.store import ExtensionStore
|
|
||||||
|
|
||||||
|
|
||||||
def _package(root: Path) -> Path:
|
|
||||||
root.mkdir()
|
|
||||||
(root / "extension.py").write_text("def register(api):\n pass\n")
|
|
||||||
(root / "nanobot.extension.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"id": "sample",
|
|
||||||
"name": "Sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"entry": "extension:register",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
async def test_service_installs_untrusted_and_updates_policy(tmp_path: Path) -> None:
|
|
||||||
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
|
||||||
|
|
||||||
installed = await service.install(
|
|
||||||
str(_package(tmp_path / "source")),
|
|
||||||
kind="local",
|
|
||||||
)
|
|
||||||
trusted = await service.set_trusted("sample", True)
|
|
||||||
await service.set_enabled("sample", False)
|
|
||||||
|
|
||||||
assert installed["record"]["trusted"] is False
|
|
||||||
assert "runtime" not in installed["manifest"]
|
|
||||||
assert trusted["record"]["trusted"] is True
|
|
||||||
assert (await service.status())["extensions"][0]["enabled"] is False
|
|
||||||
|
|
||||||
|
|
||||||
async def test_service_reports_activation_failure(tmp_path: Path) -> None:
|
|
||||||
candidate = ExtensionCandidate(
|
|
||||||
ExtensionManifest(id="broken", name="Broken", version="1.0.0"),
|
|
||||||
location=tmp_path,
|
|
||||||
trusted=True,
|
|
||||||
)
|
|
||||||
host = SimpleNamespace(
|
|
||||||
snapshot=SimpleNamespace(
|
|
||||||
catalog=SimpleNamespace(
|
|
||||||
candidates=(candidate,),
|
|
||||||
diagnostics=(),
|
|
||||||
snapshot=ExtensionSnapshot((candidate,), ()),
|
|
||||||
),
|
|
||||||
activation=SimpleNamespace(
|
|
||||||
extensions=(),
|
|
||||||
diagnostics=(
|
|
||||||
ExtensionDiagnostic(
|
|
||||||
"activation_failed",
|
|
||||||
"broken",
|
|
||||||
"missing module",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
service = ExtensionService(
|
|
||||||
host=host,
|
|
||||||
store=ExtensionStore(tmp_path / "installed"),
|
|
||||||
)
|
|
||||||
|
|
||||||
status = await service.status()
|
|
||||||
|
|
||||||
assert not status["extensions"][0]["active"]
|
|
||||||
assert status["diagnostics"][0]["code"] == "activation_failed"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_service_uninstalls_package(tmp_path: Path) -> None:
|
|
||||||
service = ExtensionService(store=ExtensionStore(tmp_path / "installed"))
|
|
||||||
await service.install(str(_package(tmp_path / "source")), kind="local")
|
|
||||||
|
|
||||||
assert await service.uninstall("sample") == {"removed": "sample"}
|
|
||||||
assert (await service.status())["extensions"] == []
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from nanobot.extensions.store import (
|
|
||||||
ExtensionSourceKind,
|
|
||||||
ExtensionStore,
|
|
||||||
InstalledExtension,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _package(root: Path, *, version: str = "1.0.0") -> Path:
|
|
||||||
root.mkdir()
|
|
||||||
(root / "extension.py").write_text("def register(api):\n pass\n")
|
|
||||||
(root / "nanobot.extension.json").write_text(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"id": "store.test",
|
|
||||||
"name": "Store test",
|
|
||||||
"version": version,
|
|
||||||
"entry": "extension:register",
|
|
||||||
"permissions": [
|
|
||||||
{"name": "network", "reason": "Fetch selected sources."}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def test_local_install_is_atomic_and_untrusted_by_default(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
|
|
||||||
result = store.install_local(_package(tmp_path / "source"))
|
|
||||||
|
|
||||||
assert result.record.source is ExtensionSourceKind.LOCAL
|
|
||||||
assert result.record.integrity.startswith("sha256:")
|
|
||||||
assert result.manifest.id == "store.test"
|
|
||||||
assert not store.discover().candidates[0].trusted
|
|
||||||
|
|
||||||
|
|
||||||
def test_policy_updates_survive_discovery(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(_package(tmp_path / "source"))
|
|
||||||
|
|
||||||
store.set_trusted("store.test", True)
|
|
||||||
store.set_enabled("store.test", False)
|
|
||||||
store.set_permissions("store.test", {"network"})
|
|
||||||
|
|
||||||
candidate = store.discover().candidates[0]
|
|
||||||
assert candidate.trusted
|
|
||||||
assert not candidate.enabled
|
|
||||||
assert candidate.granted_permissions == frozenset({"network"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_permission_cannot_be_granted(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(_package(tmp_path / "source"))
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="not requested"):
|
|
||||||
store.set_permissions("store.test", {"filesystem.write"})
|
|
||||||
|
|
||||||
|
|
||||||
def test_modified_install_loses_effective_trust(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(_package(tmp_path / "source"), trusted=True)
|
|
||||||
(store.root / "store.test" / "extension.py").write_text("changed = True\n")
|
|
||||||
|
|
||||||
discovery = store.discover()
|
|
||||||
|
|
||||||
assert not discovery.candidates[0].trusted
|
|
||||||
assert not discovery.candidates[0].integrity_valid
|
|
||||||
assert discovery.diagnostics[0].code == "extension_integrity_mismatch"
|
|
||||||
|
|
||||||
|
|
||||||
def test_changed_reinstall_revokes_trust_but_identical_reinstall_preserves_it(
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
source = _package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(source, trusted=True)
|
|
||||||
|
|
||||||
identical = store.install_local(source)
|
|
||||||
assert identical.record.trusted
|
|
||||||
|
|
||||||
(source / "extension.py").write_text("changed = True\n")
|
|
||||||
changed = store.install_local(source)
|
|
||||||
assert not changed.record.trusted
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_rejects_missing_manifest_and_symlinks(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
empty = tmp_path / "empty"
|
|
||||||
empty.mkdir()
|
|
||||||
with pytest.raises(ValueError, match="cannot read extension manifest"):
|
|
||||||
store.install_local(empty)
|
|
||||||
|
|
||||||
source = _package(tmp_path / "source")
|
|
||||||
(source / "outside").symlink_to(tmp_path)
|
|
||||||
with pytest.raises(ValueError, match="symlink"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
|
|
||||||
def test_install_rejects_source_containing_store(tmp_path: Path) -> None:
|
|
||||||
source = _package(tmp_path / "source")
|
|
||||||
store = ExtensionStore(source / "installed")
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="cannot contain the extension store"):
|
|
||||||
store.install_local(source)
|
|
||||||
|
|
||||||
|
|
||||||
def test_registry_validation_and_uninstall(tmp_path: Path) -> None:
|
|
||||||
with pytest.raises(ValueError, match="sha256 digest"):
|
|
||||||
InstalledExtension.model_validate(
|
|
||||||
{
|
|
||||||
"id": "sample",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "local",
|
|
||||||
"source_ref": "/tmp/sample",
|
|
||||||
"integrity": "invalid",
|
|
||||||
"installed_at": "2026-01-01T00:00:00Z",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(_package(tmp_path / "source"))
|
|
||||||
store.uninstall("store.test")
|
|
||||||
|
|
||||||
assert store.records() == {}
|
|
||||||
assert not (store.root / "store.test").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_registry_is_diagnostic_and_not_overwritten(tmp_path: Path) -> None:
|
|
||||||
store = ExtensionStore(tmp_path / "installed")
|
|
||||||
store.install_local(_package(tmp_path / "source"))
|
|
||||||
store.registry_path.write_text("{broken")
|
|
||||||
|
|
||||||
discovery = store.discover()
|
|
||||||
|
|
||||||
assert discovery.diagnostics[0].code == "invalid_extension_registry"
|
|
||||||
with pytest.raises(ValueError, match="invalid extension registry"):
|
|
||||||
store.set_trusted("store.test", True)
|
|
||||||
assert store.registry_path.read_text() == "{broken"
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
"""Tests for retired legacy session storage paths."""
|
"""Regression tests for legacy-stem session handling."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -7,11 +6,15 @@ from pathlib import Path
|
|||||||
from nanobot.session.manager import SessionManager
|
from nanobot.session.manager import SessionManager
|
||||||
|
|
||||||
|
|
||||||
def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
|
def test_list_sessions_repairs_corrupt_legacy_stem(tmp_path: Path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.session.manager.get_legacy_sessions_dir",
|
||||||
|
lambda: tmp_path / "legacy_sessions",
|
||||||
|
)
|
||||||
manager = SessionManager(tmp_path / "workspace")
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
|
||||||
# A legacy lossy-path filename must not be treated as current session storage,
|
# Simulate a legacy lossy-path filename (telegram_12345.jsonl) with a corrupt
|
||||||
# even when the file contains otherwise recoverable records.
|
# first line that triggers the repair branch in list_sessions.
|
||||||
legacy_stem = "telegram_12345"
|
legacy_stem = "telegram_12345"
|
||||||
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
|
corrupt_path = manager.sessions_dir / f"{legacy_stem}.jsonl"
|
||||||
corrupt_path.parent.mkdir(parents=True, exist_ok=True)
|
corrupt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -21,6 +24,7 @@ def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
|
|||||||
"created_at": datetime(2025, 1, 1).isoformat(),
|
"created_at": datetime(2025, 1, 1).isoformat(),
|
||||||
"updated_at": datetime(2025, 1, 1).isoformat(),
|
"updated_at": datetime(2025, 1, 1).isoformat(),
|
||||||
})
|
})
|
||||||
|
# Corrupt line followed by valid message
|
||||||
corrupt_path.write_text(
|
corrupt_path.write_text(
|
||||||
metadata + "\n{INVALID JSON LINE\n"
|
metadata + "\n{INVALID JSON LINE\n"
|
||||||
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
|
+ json.dumps({"role": "user", "content": "recoverable message"}) + "\n",
|
||||||
@@ -29,11 +33,14 @@ def test_list_sessions_ignores_legacy_stem(tmp_path: Path) -> None:
|
|||||||
|
|
||||||
sessions = manager.list_sessions()
|
sessions = manager.list_sessions()
|
||||||
|
|
||||||
assert sessions == []
|
# BUG: repair fails because _repair re-encodes the fallback_key via
|
||||||
assert corrupt_path.exists()
|
# _get_session_path, producing a base64 stem that doesn't match the
|
||||||
|
# actual legacy filename. The session is silently dropped.
|
||||||
|
assert len(sessions) == 1, f"Expected 1 session, got {len(sessions)}"
|
||||||
|
assert sessions[0]["key"] == "telegram:12345"
|
||||||
|
|
||||||
|
|
||||||
def test_read_session_methods_ignore_legacy_lossy_stem(
|
def test_read_session_methods_fall_back_to_legacy_lossy_stem(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -62,5 +69,8 @@ def test_read_session_methods_ignore_legacy_lossy_stem(
|
|||||||
metadata_result = manager.read_session_metadata(key)
|
metadata_result = manager.read_session_metadata(key)
|
||||||
file_result = manager.read_session_file(key)
|
file_result = manager.read_session_file(key)
|
||||||
|
|
||||||
assert metadata_result is None
|
assert metadata_result is not None
|
||||||
assert file_result is None
|
assert metadata_result["metadata"] == metadata["metadata"]
|
||||||
|
assert file_result is not None
|
||||||
|
assert file_result["metadata"] == metadata["metadata"]
|
||||||
|
assert file_result["messages"] == []
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ def test_write_stdin_reports_missing_session(tmp_path):
|
|||||||
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
|
result = asyncio.run(tool.execute(session_id="missing\nExit code: 0", chars=""))
|
||||||
|
|
||||||
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
|
assert result == "Error: exec session not found: 'missing\\nExit code: 0'"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result("write_stdin", result)
|
||||||
|
|
||||||
|
|
||||||
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
def test_list_exec_sessions_reports_running_commands(tmp_path):
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ async def test_execute_wraps_mcp_is_error_result() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "Error: server-side MCP failure"
|
assert result == "Error: server-side MCP failure"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -462,7 +462,7 @@ async def test_execute_contains_malformed_success_result() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool returned malformed content: TypeError)"
|
assert result == "(MCP tool returned malformed content: TypeError)"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -476,7 +476,7 @@ async def test_registry_adds_retry_hint_to_malformed_mcp_result() -> None:
|
|||||||
|
|
||||||
result = await registry.execute(wrapper.name, {})
|
result = await registry.execute(wrapper.name, {})
|
||||||
|
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
assert "MCP tool returned malformed content" in result
|
assert "MCP tool returned malformed content" in result
|
||||||
assert "Analyze the error above and try a different approach" in result
|
assert "Analyze the error above and try a different approach" in result
|
||||||
|
|
||||||
@@ -494,7 +494,7 @@ async def test_execute_preserves_success_text_that_starts_with_error() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "Error: generated report successfully"
|
assert result == "Error: generated report successfully"
|
||||||
assert not is_tool_error_result(result)
|
assert not is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
# Smallest valid 1x1 PNG, base64 without the data: prefix.
|
# Smallest valid 1x1 PNG, base64 without the data: prefix.
|
||||||
@@ -562,7 +562,7 @@ async def test_execute_returns_timeout_message() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call timed out after 0.01s)"
|
assert result == "(MCP tool call timed out after 0.01s)"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -575,7 +575,7 @@ async def test_execute_handles_server_cancelled_error() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call was cancelled)"
|
assert result == "(MCP tool call was cancelled)"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -607,7 +607,7 @@ async def test_execute_handles_generic_exception() -> None:
|
|||||||
result = await wrapper.execute()
|
result = await wrapper.execute()
|
||||||
|
|
||||||
assert result == "(MCP tool call failed: RuntimeError)"
|
assert result == "(MCP tool call failed: RuntimeError)"
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(wrapper.name, result)
|
||||||
|
|
||||||
|
|
||||||
def _make_tool_def(name: str) -> SimpleNamespace:
|
def _make_tool_def(name: str) -> SimpleNamespace:
|
||||||
@@ -1631,7 +1631,7 @@ def test_long_server_name_tools_are_matched_by_server_name() -> None:
|
|||||||
assert wrapper._reconnect is not None
|
assert wrapper._reconnect is not None
|
||||||
assert other_wrapper._reconnect is None
|
assert other_wrapper._reconnect is None
|
||||||
|
|
||||||
removed = mcp_mod._unregister_server_tools(registry, server_name)
|
removed = mcp_mod._unregister_server_tools(SimpleNamespace(), registry, server_name)
|
||||||
|
|
||||||
assert removed == 1
|
assert removed == 1
|
||||||
assert wrapper.name not in registry.tool_names
|
assert wrapper.name not in registry.tool_names
|
||||||
|
|||||||
@@ -108,16 +108,6 @@ def test_suggest_name_updates_after_register_and_unregister() -> None:
|
|||||||
assert registry._suggest_name("read-file") == "readFile"
|
assert registry._suggest_name("read-file") == "readFile"
|
||||||
|
|
||||||
|
|
||||||
def test_registry_tracks_and_removes_tool_owner() -> None:
|
|
||||||
registry = ToolRegistry()
|
|
||||||
registry.register(_FakeTool("custom"), owner="acme.tools")
|
|
||||||
|
|
||||||
assert registry.owner("custom") == "acme.tools"
|
|
||||||
|
|
||||||
registry.unregister("custom")
|
|
||||||
assert registry.owner("custom") is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
|
def test_prepare_call_read_file_rejects_non_object_params_with_actionable_hint() -> None:
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
registry.register(_FakeTool("read_file"))
|
registry.register(_FakeTool("read_file"))
|
||||||
@@ -322,19 +312,6 @@ def test_register_invalidates_cache() -> None:
|
|||||||
assert len(second) == 2
|
assert len(second) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_register_if_absent_preserves_existing_tool_and_owner() -> None:
|
|
||||||
registry = ToolRegistry()
|
|
||||||
existing = _FakeTool("read_file")
|
|
||||||
registry.register(existing, owner="extension.example")
|
|
||||||
|
|
||||||
assert not registry.register_if_absent(
|
|
||||||
_FakeTool("read_file"),
|
|
||||||
owner="nanobot.mcp.example",
|
|
||||||
)
|
|
||||||
assert registry.get("read_file") is existing
|
|
||||||
assert registry.owner("read_file") == "extension.example"
|
|
||||||
|
|
||||||
|
|
||||||
def test_unregister_invalidates_cache() -> None:
|
def test_unregister_invalidates_cache() -> None:
|
||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
registry.register(_FakeTool("read_file"))
|
registry.register(_FakeTool("read_file"))
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class SampleTool(Tool):
|
|||||||
@tool_parameters(
|
@tool_parameters(
|
||||||
tool_parameters_schema(
|
tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(minimum=1, maximum=10),
|
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -81,12 +81,12 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
|
|||||||
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
|
"""ObjectSchema.validate_value 与 validate_json_schema_value、Tool.validate_params 一致。"""
|
||||||
root = tool_parameters_schema(
|
root = tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(minimum=1, maximum=10),
|
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
obj = ObjectSchema(
|
obj = ObjectSchema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(minimum=1, maximum=10),
|
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||||
required=["query", "count"],
|
required=["query", "count"],
|
||||||
)
|
)
|
||||||
params = {"query": "h", "count": 2}
|
params = {"query": "h", "count": 2}
|
||||||
@@ -110,14 +110,14 @@ def test_schema_validate_value_matches_tool_validate_params() -> None:
|
|||||||
expected = _Mini().validate_params(params)
|
expected = _Mini().validate_params(params)
|
||||||
assert Schema.validate_json_schema_value(params, root, "") == expected
|
assert Schema.validate_json_schema_value(params, root, "") == expected
|
||||||
assert obj.validate_value(params, "") == expected
|
assert obj.validate_value(params, "") == expected
|
||||||
assert IntegerSchema(minimum=1).validate_value(0, "n") == ["n must be >= 1"]
|
assert IntegerSchema(0, minimum=1).validate_value(0, "n") == ["n must be >= 1"]
|
||||||
|
|
||||||
|
|
||||||
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
|
def test_schema_classes_equivalent_to_sample_tool_parameters() -> None:
|
||||||
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
|
"""Schema 类生成的 JSON Schema 应与手写 dict 一致,便于校验行为一致。"""
|
||||||
built = tool_parameters_schema(
|
built = tool_parameters_schema(
|
||||||
query=StringSchema(min_length=2),
|
query=StringSchema(min_length=2),
|
||||||
count=IntegerSchema(minimum=1, maximum=10),
|
count=IntegerSchema(2, minimum=1, maximum=10),
|
||||||
mode=StringSchema("", enum=["fast", "full"]),
|
mode=StringSchema("", enum=["fast", "full"]),
|
||||||
meta=ObjectSchema(
|
meta=ObjectSchema(
|
||||||
tag=StringSchema(""),
|
tag=StringSchema(""),
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ async def test_serper_search_http_error(monkeypatch):
|
|||||||
tool = _tool(provider="serper", api_key="bad-serper-key")
|
tool = _tool(provider="serper", api_key="bad-serper-key")
|
||||||
result = await tool.execute(query="serper")
|
result = await tool.execute(query="serper")
|
||||||
assert "Error: Serper search failed (403)" in result
|
assert "Error: Serper search failed (403)" in result
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(tool.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -284,7 +284,7 @@ async def test_serper_search_rate_limited(monkeypatch):
|
|||||||
tool = _tool(provider="serper", api_key="serper-key")
|
tool = _tool(provider="serper", api_key="serper-key")
|
||||||
result = await tool.execute(query="serper")
|
result = await tool.execute(query="serper")
|
||||||
assert "Serper search rate limited" in result
|
assert "Serper search rate limited" in result
|
||||||
assert is_tool_error_result(result)
|
assert is_tool_error_result(tool.name, result)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
"project_name_overrides": {"/repo": " Core ", "bad": ""},
|
||||||
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
"tags_by_key": {"websocket:a": ["work", "work", ""]},
|
||||||
"collapsed_groups": {"Earlier": 1},
|
"collapsed_groups": {"Earlier": 1},
|
||||||
|
"activity_seen_at_by_key": {
|
||||||
|
"websocket:a": " 2026-07-27T08:30:00Z ",
|
||||||
|
"empty": "",
|
||||||
|
"invalid": 123,
|
||||||
|
},
|
||||||
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
"view": {"density": "tiny", "show_archived": True, "sort": "nope"},
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
@@ -45,6 +50,9 @@ def test_sidebar_state_normalizes_old_or_partial_payload(tmp_path, monkeypatch)
|
|||||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||||
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
assert state["tags_by_key"] == {"websocket:a": ["work"]}
|
||||||
assert state["collapsed_groups"] == {"Earlier": True}
|
assert state["collapsed_groups"] == {"Earlier": True}
|
||||||
|
assert state["activity_seen_at_by_key"] == {
|
||||||
|
"websocket:a": "2026-07-27T08:30:00Z"
|
||||||
|
}
|
||||||
assert state["view"] == {
|
assert state["view"] == {
|
||||||
"density": "comfortable",
|
"density": "comfortable",
|
||||||
"show_previews": False,
|
"show_previews": False,
|
||||||
@@ -63,6 +71,9 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
|||||||
"archived_keys": ["websocket:b"],
|
"archived_keys": ["websocket:b"],
|
||||||
"title_overrides": {"websocket:a": "Release"},
|
"title_overrides": {"websocket:a": "Release"},
|
||||||
"project_name_overrides": {"/repo": "Core"},
|
"project_name_overrides": {"/repo": "Core"},
|
||||||
|
"activity_seen_at_by_key": {
|
||||||
|
"websocket:a": "2026-07-27T08:30:00Z"
|
||||||
|
},
|
||||||
"view": {"density": "compact", "show_previews": True},
|
"view": {"density": "compact", "show_previews": True},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -71,7 +82,14 @@ def test_sidebar_state_write_is_scoped_to_config_data_dir(tmp_path, monkeypatch)
|
|||||||
assert state["archived_keys"] == ["websocket:b"]
|
assert state["archived_keys"] == ["websocket:b"]
|
||||||
assert state["title_overrides"] == {"websocket:a": "Release"}
|
assert state["title_overrides"] == {"websocket:a": "Release"}
|
||||||
assert state["project_name_overrides"] == {"/repo": "Core"}
|
assert state["project_name_overrides"] == {"/repo": "Core"}
|
||||||
|
assert state["activity_seen_at_by_key"] == {
|
||||||
|
"websocket:a": "2026-07-27T08:30:00Z"
|
||||||
|
}
|
||||||
assert state["view"]["density"] == "compact"
|
assert state["view"]["density"] == "compact"
|
||||||
assert state["view"]["show_previews"] is True
|
assert state["view"]["show_previews"] is True
|
||||||
assert webui_sidebar_state_path().is_file()
|
assert webui_sidebar_state_path().is_file()
|
||||||
assert read_webui_sidebar_state()["pinned_keys"] == ["websocket:a"]
|
persisted = read_webui_sidebar_state()
|
||||||
|
assert persisted["pinned_keys"] == ["websocket:a"]
|
||||||
|
assert persisted["activity_seen_at_by_key"] == {
|
||||||
|
"websocket:a": "2026-07-27T08:30:00Z"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from websockets.datastructures import Headers
|
|
||||||
|
|
||||||
from nanobot.webui.extensions_routes import WebUIExtensionsRouter
|
|
||||||
from nanobot.webui.http_utils import http_json_response
|
|
||||||
|
|
||||||
|
|
||||||
class _Service:
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.calls: list[tuple[str, object]] = []
|
|
||||||
|
|
||||||
async def status(self):
|
|
||||||
self.calls.append(("status", None))
|
|
||||||
return {"extensions": [], "diagnostics": []}
|
|
||||||
|
|
||||||
async def install(self, source, *, kind, ref, trusted):
|
|
||||||
self.calls.append(("install", (source, kind, ref, trusted)))
|
|
||||||
return {"record": {"id": "sample"}}
|
|
||||||
|
|
||||||
async def set_trusted(self, extension_id, trusted):
|
|
||||||
self.calls.append(("trust", (extension_id, trusted)))
|
|
||||||
return {"record": {"id": extension_id}}
|
|
||||||
|
|
||||||
async def set_permissions(self, extension_id, permissions):
|
|
||||||
self.calls.append(("permissions", (extension_id, permissions)))
|
|
||||||
return {"record": {"id": extension_id}}
|
|
||||||
|
|
||||||
|
|
||||||
def _router(
|
|
||||||
service: _Service,
|
|
||||||
*,
|
|
||||||
authorized: bool = True,
|
|
||||||
allow_remote: bool = False,
|
|
||||||
) -> WebUIExtensionsRouter:
|
|
||||||
return WebUIExtensionsRouter(
|
|
||||||
service=service,
|
|
||||||
check_api_token=lambda _request: authorized,
|
|
||||||
json_response=http_json_response,
|
|
||||||
error_response=lambda status, message: http_json_response(
|
|
||||||
{"error": message},
|
|
||||||
status=status,
|
|
||||||
),
|
|
||||||
allow_remote_package_install=allow_remote,
|
|
||||||
logger=SimpleNamespace(exception=lambda *_args: None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _request(
|
|
||||||
path: str,
|
|
||||||
*,
|
|
||||||
method: str = "GET",
|
|
||||||
values: dict[str, object] | None = None,
|
|
||||||
host: str = "127.0.0.1:8765",
|
|
||||||
):
|
|
||||||
headers = Headers([("Host", host)])
|
|
||||||
if values is not None:
|
|
||||||
headers["X-Nanobot-Extension-Values"] = quote(json.dumps(values))
|
|
||||||
return SimpleNamespace(path=path, method=method, headers=headers)
|
|
||||||
|
|
||||||
|
|
||||||
_LOCAL = SimpleNamespace(remote_address=("127.0.0.1", 12345))
|
|
||||||
_REMOTE = SimpleNamespace(remote_address=("192.0.2.1", 12345))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_extension_status_requires_auth_and_get() -> None:
|
|
||||||
service = _Service()
|
|
||||||
|
|
||||||
unauthorized = await _router(service, authorized=False).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request("/api/extensions"),
|
|
||||||
"/api/extensions",
|
|
||||||
)
|
|
||||||
wrong_method = await _router(service).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request("/api/extensions", method="POST"),
|
|
||||||
"/api/extensions",
|
|
||||||
)
|
|
||||||
response = await _router(service).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request("/api/extensions"),
|
|
||||||
"/api/extensions",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert unauthorized is not None and unauthorized.status_code == 401
|
|
||||||
assert wrong_method is not None and wrong_method.status_code == 405
|
|
||||||
assert response is not None and response.status_code == 200
|
|
||||||
assert service.calls == [("status", None)]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_local_install_is_untrusted() -> None:
|
|
||||||
service = _Service()
|
|
||||||
response = await _router(service).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/install",
|
|
||||||
method="POST",
|
|
||||||
values={"source": "https://example.com/acme.git", "kind": "git"},
|
|
||||||
),
|
|
||||||
"/api/extensions/install",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response is not None and response.status_code == 200
|
|
||||||
assert service.calls == [
|
|
||||||
("install", ("https://example.com/acme.git", "git", "", False)),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remote_policy_allows_git_but_never_local_paths() -> None:
|
|
||||||
service = _Service()
|
|
||||||
denied = await _router(service).dispatch(
|
|
||||||
_REMOTE,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/install",
|
|
||||||
method="POST",
|
|
||||||
values={"source": "https://example.com/acme.git", "kind": "git"},
|
|
||||||
),
|
|
||||||
"/api/extensions/install",
|
|
||||||
)
|
|
||||||
allowed = await _router(service, allow_remote=True).dispatch(
|
|
||||||
_REMOTE,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/install",
|
|
||||||
method="POST",
|
|
||||||
values={"source": "https://example.com/acme.git", "kind": "git"},
|
|
||||||
),
|
|
||||||
"/api/extensions/install",
|
|
||||||
)
|
|
||||||
local_denied = await _router(service, allow_remote=True).dispatch(
|
|
||||||
_REMOTE,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/install",
|
|
||||||
method="POST",
|
|
||||||
values={"source": "/tmp/example", "kind": "local"},
|
|
||||||
),
|
|
||||||
"/api/extensions/install",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert denied is not None and denied.status_code == 403
|
|
||||||
assert allowed is not None and allowed.status_code == 200
|
|
||||||
assert local_denied is not None and local_denied.status_code == 403
|
|
||||||
assert service.calls == [
|
|
||||||
("install", ("https://example.com/acme.git", "git", "", False)),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_remote_clients_cannot_change_trust() -> None:
|
|
||||||
service = _Service()
|
|
||||||
response = await _router(service, allow_remote=True).dispatch(
|
|
||||||
_REMOTE,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/trust",
|
|
||||||
method="POST",
|
|
||||||
values={"id": "sample"},
|
|
||||||
),
|
|
||||||
"/api/extensions/trust",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response is not None and response.status_code == 403
|
|
||||||
assert service.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_permissions_require_an_array_of_strings() -> None:
|
|
||||||
response = await _router(_Service()).dispatch(
|
|
||||||
_LOCAL,
|
|
||||||
_request(
|
|
||||||
"/api/extensions/permissions",
|
|
||||||
method="POST",
|
|
||||||
values={"id": "sample", "permissions": "network"},
|
|
||||||
),
|
|
||||||
"/api/extensions/permissions",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response is not None and response.status_code == 400
|
|
||||||
@@ -98,20 +98,6 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
|||||||
assert list_webui_sessions(manager) == []
|
assert list_webui_sessions(manager) == []
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
|
||||||
manager = SessionManager(tmp_path)
|
|
||||||
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
|
||||||
legacy_path.write_text(
|
|
||||||
'{"_type":"metadata","key":"websocket:legacy",'
|
|
||||||
'"created_at":"2025-01-01T00:00:00",'
|
|
||||||
'"updated_at":"2025-01-01T00:00:00","metadata":{}}\n',
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert list_webui_sessions(manager) == []
|
|
||||||
assert legacy_path.exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
|
def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
session = manager.get_or_create("websocket:cron-preview")
|
session = manager.get_or_create("websocket:cron-preview")
|
||||||
|
|||||||
+63
-61
@@ -90,13 +90,7 @@ const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
|||||||
const PAIRING_POLL_INTERVAL_MS = 5_000;
|
const PAIRING_POLL_INTERVAL_MS = 5_000;
|
||||||
const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000;
|
const PAIRING_IDLE_POLL_INTERVAL_MS = 15_000;
|
||||||
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
|
const PAIRING_DISMISS_SNOOZE_MS = 30_000;
|
||||||
type ShellView =
|
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||||
| "chat"
|
|
||||||
| "settings"
|
|
||||||
| "apps"
|
|
||||||
| "automations"
|
|
||||||
| "skills"
|
|
||||||
| "extensions";
|
|
||||||
type ShellRoute = {
|
type ShellRoute = {
|
||||||
view: ShellView;
|
view: ShellView;
|
||||||
activeKey: string | null;
|
activeKey: string | null;
|
||||||
@@ -108,10 +102,6 @@ const SettingsView = lazy(async () => {
|
|||||||
const module = await loadSettingsView();
|
const module = await loadSettingsView();
|
||||||
return { default: module.SettingsView };
|
return { default: module.SettingsView };
|
||||||
});
|
});
|
||||||
const ExtensionsView = lazy(async () => {
|
|
||||||
const module = await import("@/components/ExtensionsView");
|
|
||||||
return { default: module.ExtensionsView };
|
|
||||||
});
|
|
||||||
const SessionSearchDialog = lazy(async () => {
|
const SessionSearchDialog = lazy(async () => {
|
||||||
const module = await import("@/components/SessionSearchDialog");
|
const module = await import("@/components/SessionSearchDialog");
|
||||||
return { default: module.SessionSearchDialog };
|
return { default: module.SessionSearchDialog };
|
||||||
@@ -235,9 +225,6 @@ function readShellRoute(): ShellRoute {
|
|||||||
if (path === "/skills") {
|
if (path === "/skills") {
|
||||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||||
}
|
}
|
||||||
if (path === "/extensions") {
|
|
||||||
return { view: "extensions", activeKey, settingsSection: "overview" };
|
|
||||||
}
|
|
||||||
if (path.startsWith("/chat/")) {
|
if (path.startsWith("/chat/")) {
|
||||||
const encoded = path.slice("/chat/".length);
|
const encoded = path.slice("/chat/".length);
|
||||||
try {
|
try {
|
||||||
@@ -385,6 +372,15 @@ function writeSessionUpdateChatIds(chatIds: Set<string>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActivityNewer(updatedAt: string | null, seenAt: string | undefined): boolean {
|
||||||
|
if (!updatedAt || !seenAt) return false;
|
||||||
|
const updatedTime = Date.parse(updatedAt);
|
||||||
|
const seenTime = Date.parse(seenAt);
|
||||||
|
return Number.isFinite(updatedTime)
|
||||||
|
&& Number.isFinite(seenTime)
|
||||||
|
&& updatedTime > seenTime;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
|
function normalizeWorkspaceScope(scope: WorkspaceScopePayload): WorkspaceScopePayload {
|
||||||
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
|
const accessMode = scope.access_mode === "restricted" ? "restricted" : "full";
|
||||||
return {
|
return {
|
||||||
@@ -1117,7 +1113,20 @@ function Shell({
|
|||||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||||
}, [sessions, activeKey]);
|
}, [sessions, activeKey]);
|
||||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
const updatedChatIdList = useMemo(() => {
|
||||||
|
const combined = new Set(updatedChatIds);
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (
|
||||||
|
isActivityNewer(
|
||||||
|
session.updatedAt,
|
||||||
|
sidebarState.activity_seen_at_by_key[session.key],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
combined.add(session.chatId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(combined);
|
||||||
|
}, [sessions, sidebarState.activity_seen_at_by_key, updatedChatIds]);
|
||||||
const activeChatId = activeSession?.chatId ?? null;
|
const activeChatId = activeSession?.chatId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
activeChatIdRef.current = activeChatId;
|
activeChatIdRef.current = activeChatId;
|
||||||
@@ -1128,7 +1137,26 @@ function Shell({
|
|||||||
next.delete(activeChatId);
|
next.delete(activeChatId);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [activeChatId]);
|
const activityAt = activeSession?.updatedAt;
|
||||||
|
const sessionKey = activeSession?.key;
|
||||||
|
if (!activityAt || !sessionKey) return;
|
||||||
|
void updateSidebarState((current) => {
|
||||||
|
const seenAt = current.activity_seen_at_by_key[sessionKey];
|
||||||
|
if (seenAt && !isActivityNewer(activityAt, seenAt)) return current;
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
activity_seen_at_by_key: {
|
||||||
|
...current.activity_seen_at_by_key,
|
||||||
|
[sessionKey]: activityAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
activeChatId,
|
||||||
|
activeSession?.key,
|
||||||
|
activeSession?.updatedAt,
|
||||||
|
updateSidebarState,
|
||||||
|
]);
|
||||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||||
return workspaceOverrides[activeChatId];
|
return workspaceOverrides[activeChatId];
|
||||||
@@ -1666,12 +1694,6 @@ function Shell({
|
|||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [activeKey, navigate]);
|
}, [activeKey, navigate]);
|
||||||
|
|
||||||
const onOpenExtensions = useCallback(() => {
|
|
||||||
setSessionSearchOpen(false);
|
|
||||||
navigate({ view: "extensions", activeKey, settingsSection: "overview" });
|
|
||||||
setMobileSidebarOpen(false);
|
|
||||||
}, [activeKey, navigate]);
|
|
||||||
|
|
||||||
const onSettingsSectionChange = useCallback(
|
const onSettingsSectionChange = useCallback(
|
||||||
(section: SettingsSectionKey) => {
|
(section: SettingsSectionKey) => {
|
||||||
navigate({
|
navigate({
|
||||||
@@ -1899,12 +1921,6 @@ function Shell({
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (view === "extensions") {
|
|
||||||
document.title = t("app.documentTitle.chat", {
|
|
||||||
title: t("extensions.title", { defaultValue: "Extensions" }),
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
document.title = activeSession
|
document.title = activeSession
|
||||||
? t("app.documentTitle.chat", { title: headerTitle })
|
? t("app.documentTitle.chat", { title: headerTitle })
|
||||||
: t("app.documentTitle.base");
|
: t("app.documentTitle.base");
|
||||||
@@ -1927,16 +1943,9 @@ function Shell({
|
|||||||
onOpenApps,
|
onOpenApps,
|
||||||
onOpenAutomations,
|
onOpenAutomations,
|
||||||
onOpenSkills,
|
onOpenSkills,
|
||||||
onOpenExtensions,
|
|
||||||
onSettingsIntent,
|
onSettingsIntent,
|
||||||
onOpenSearch: onOpenSessionSearch,
|
onOpenSearch: onOpenSessionSearch,
|
||||||
activeUtility:
|
activeUtility: view === "apps" || view === "automations" || view === "skills" ? view : null,
|
||||||
view === "apps"
|
|
||||||
|| view === "automations"
|
|
||||||
|| view === "skills"
|
|
||||||
|| view === "extensions"
|
|
||||||
? view
|
|
||||||
: null,
|
|
||||||
onToggleArchived,
|
onToggleArchived,
|
||||||
pinnedKeys: sidebarState.pinned_keys,
|
pinnedKeys: sidebarState.pinned_keys,
|
||||||
archivedKeys: sidebarState.archived_keys,
|
archivedKeys: sidebarState.archived_keys,
|
||||||
@@ -2129,31 +2138,24 @@ function Shell({
|
|||||||
{view !== "chat" && (
|
{view !== "chat" && (
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
<Suspense fallback={<SurfaceLoadingFallback />}>
|
<Suspense fallback={<SurfaceLoadingFallback />}>
|
||||||
{view === "extensions" ? (
|
<SettingsView
|
||||||
<ExtensionsView
|
theme={theme}
|
||||||
onBackToChat={onBackToChat}
|
initialSection={settingsInitialSection}
|
||||||
hostChromeInset={showHostChrome}
|
initialSettings={settingsSnapshot}
|
||||||
/>
|
showSidebar={view === "settings"}
|
||||||
) : (
|
onToggleTheme={toggle}
|
||||||
<SettingsView
|
onBackToChat={onBackToChat}
|
||||||
theme={theme}
|
onModelNameChange={onModelNameChange}
|
||||||
initialSection={settingsInitialSection}
|
onSettingsChange={setSettingsSnapshot}
|
||||||
initialSettings={settingsSnapshot}
|
skills={skills}
|
||||||
showSidebar={view === "settings"}
|
onWorkspaceSettingsChange={refreshWorkspaces}
|
||||||
onToggleTheme={toggle}
|
onSectionChange={onSettingsSectionChange}
|
||||||
onBackToChat={onBackToChat}
|
onLogout={onLogout}
|
||||||
onModelNameChange={onModelNameChange}
|
onRestart={onRestart}
|
||||||
onSettingsChange={setSettingsSnapshot}
|
onNativeEngineRestart={onNativeEngineRestart}
|
||||||
skills={skills}
|
isRestarting={isRestarting}
|
||||||
onWorkspaceSettingsChange={refreshWorkspaces}
|
hostChromeInset={showHostChrome}
|
||||||
onSectionChange={onSettingsSectionChange}
|
/>
|
||||||
onLogout={onLogout}
|
|
||||||
onRestart={onRestart}
|
|
||||||
onNativeEngineRestart={onNativeEngineRestart}
|
|
||||||
isRestarting={isRestarting}
|
|
||||||
hostChromeInset={showHostChrome}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { ExtensionsCatalog } from "@/components/extensions/ExtensionsCatalog";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ExtensionsViewProps {
|
|
||||||
hostChromeInset?: boolean;
|
|
||||||
onBackToChat: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExtensionsView({
|
|
||||||
hostChromeInset = false,
|
|
||||||
onBackToChat,
|
|
||||||
}: ExtensionsViewProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="h-full min-w-0 overflow-y-auto bg-settings-canvas [scrollbar-gutter:stable]">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mx-auto w-full max-w-[920px] px-4 py-6 sm:px-8 sm:py-8 lg:py-12",
|
|
||||||
hostChromeInset && "pt-[4.25rem] sm:pt-[4.25rem] lg:pt-[4.75rem]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBackToChat}
|
|
||||||
className="touch-target mb-4 inline-flex items-center gap-1.5 rounded-full px-2.5 py-1.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-muted/70 hover:text-foreground lg:hidden"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-3.5 w-3.5" aria-hidden />
|
|
||||||
{t("extensions.backToChat")}
|
|
||||||
</button>
|
|
||||||
<h1 className="mb-7 text-[24px] font-normal leading-tight tracking-normal text-foreground sm:text-[28px]">
|
|
||||||
{t("extensions.title")}
|
|
||||||
</h1>
|
|
||||||
<ExtensionsCatalog />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Brain,
|
Brain,
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Menu,
|
Menu,
|
||||||
PackageOpen,
|
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
SquarePen,
|
SquarePen,
|
||||||
@@ -37,11 +36,10 @@ interface SidebarProps {
|
|||||||
onOpenSettings: () => void;
|
onOpenSettings: () => void;
|
||||||
onOpenApps: () => void;
|
onOpenApps: () => void;
|
||||||
onOpenSkills: () => void;
|
onOpenSkills: () => void;
|
||||||
onOpenExtensions: () => void;
|
|
||||||
onOpenAutomations: () => void;
|
onOpenAutomations: () => void;
|
||||||
onSettingsIntent?: () => void;
|
onSettingsIntent?: () => void;
|
||||||
onOpenSearch: () => void;
|
onOpenSearch: () => void;
|
||||||
activeUtility?: "apps" | "skills" | "extensions" | "automations" | null;
|
activeUtility?: "apps" | "skills" | "automations" | null;
|
||||||
onToggleArchived: () => void;
|
onToggleArchived: () => void;
|
||||||
onCollapse: () => void;
|
onCollapse: () => void;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
@@ -171,13 +169,6 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
active={props.activeUtility === "skills"}
|
active={props.activeUtility === "skills"}
|
||||||
icon={<Brain className="h-4 w-4" />}
|
icon={<Brain className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
<SidebarActionButton
|
|
||||||
collapsed={collapsed}
|
|
||||||
label={t("sidebar.extensions")}
|
|
||||||
onClick={props.onOpenExtensions}
|
|
||||||
active={props.activeUtility === "extensions"}
|
|
||||||
icon={<PackageOpen className="h-4 w-4" />}
|
|
||||||
/>
|
|
||||||
<SidebarActionButton
|
<SidebarActionButton
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
label={t("sidebar.automations", { defaultValue: "Automations" })}
|
label={t("sidebar.automations", { defaultValue: "Automations" })}
|
||||||
|
|||||||
@@ -1,262 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "@/components/ui/sheet";
|
|
||||||
import type { ExtensionAction } from "@/lib/api";
|
|
||||||
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import {
|
|
||||||
DetailPill,
|
|
||||||
DetailSection,
|
|
||||||
ExtensionMark,
|
|
||||||
MetaItem,
|
|
||||||
NamedItems,
|
|
||||||
StatusBadge,
|
|
||||||
} from "./extension-ui";
|
|
||||||
|
|
||||||
interface ExtensionDetailSheetProps {
|
|
||||||
extension: ExtensionInfo | null;
|
|
||||||
diagnostics: ExtensionDiagnosticInfo[];
|
|
||||||
busy: string | null;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onAction: (
|
|
||||||
action: ExtensionAction,
|
|
||||||
values: Record<string, unknown>,
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExtensionDetailSheet({
|
|
||||||
extension,
|
|
||||||
diagnostics,
|
|
||||||
busy,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onAction,
|
|
||||||
}: ExtensionDetailSheetProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [uninstallOpen, setUninstallOpen] = useState(false);
|
|
||||||
if (!extension) return null;
|
|
||||||
|
|
||||||
const requested = new Set(extension.requested_permissions);
|
|
||||||
const granted = new Set(extension.granted_permissions);
|
|
||||||
const allGranted = [...requested].every((permission) => granted.has(permission));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
||||||
<SheetContent
|
|
||||||
side="right"
|
|
||||||
className="w-[min(36rem,calc(100vw-1rem))] max-w-none gap-0 overflow-hidden p-0 sm:max-w-none"
|
|
||||||
>
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<div className="flex items-start gap-3 pr-8">
|
|
||||||
<ExtensionMark large />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<SheetTitle className="truncate text-[20px] font-semibold">
|
|
||||||
{extension.name}
|
|
||||||
</SheetTitle>
|
|
||||||
<SheetDescription className="mt-1 line-clamp-2 text-[13px]">
|
|
||||||
{extension.description || extension.id}
|
|
||||||
</SheetDescription>
|
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
||||||
<DetailPill>{extension.version}</DetailPill>
|
|
||||||
<StatusBadge extension={extension} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-7 space-y-6">
|
|
||||||
<DetailSection title={t("extensions.details.identity")}>
|
|
||||||
<dl className="grid grid-cols-2 gap-2">
|
|
||||||
<MetaItem label="ID" value={extension.id} />
|
|
||||||
<MetaItem
|
|
||||||
label={t("extensions.details.source")}
|
|
||||||
value={extension.source}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t("extensions.details.license")}
|
|
||||||
value={extension.license || "—"}
|
|
||||||
/>
|
|
||||||
</dl>
|
|
||||||
{extension.homepage ? (
|
|
||||||
<a
|
|
||||||
href={extension.homepage}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="mt-2 inline-flex items-center gap-1 text-[12px] text-link hover:underline"
|
|
||||||
>
|
|
||||||
{t("extensions.details.homepage")}
|
|
||||||
<ExternalLink className="h-3 w-3" aria-hidden />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
<NamedItems
|
|
||||||
title={t("extensions.details.dependencies")}
|
|
||||||
rows={extension.dependencies.map((item) => ({
|
|
||||||
name: item.name,
|
|
||||||
meta: `${item.kind}${item.specifier ? ` ${item.specifier}` : ""}`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DetailSection title={t("extensions.details.permissions")}>
|
|
||||||
{extension.permissions.length ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{extension.permissions.map((permission) => (
|
|
||||||
<div
|
|
||||||
key={permission.name}
|
|
||||||
className="flex items-start justify-between gap-3 rounded-[14px] bg-muted/35 px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="text-[13px] font-medium text-foreground">
|
|
||||||
{permission.name}
|
|
||||||
</div>
|
|
||||||
{permission.reason ? (
|
|
||||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
|
||||||
{permission.reason}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"shrink-0 text-[11px]",
|
|
||||||
granted.has(permission.name)
|
|
||||||
? "text-emerald-600 dark:text-emerald-300"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{granted.has(permission.name)
|
|
||||||
? t("extensions.permissionGranted")
|
|
||||||
: t("extensions.permissionPending")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={busy !== null}
|
|
||||||
onClick={() =>
|
|
||||||
void onAction("permissions", {
|
|
||||||
id: extension.id,
|
|
||||||
permissions: allGranted ? [] : [...requested],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
{allGranted
|
|
||||||
? t("extensions.revokePermissions")
|
|
||||||
: t("extensions.grantPermissions")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-[13px] text-muted-foreground">
|
|
||||||
{t("extensions.noPermissions")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
|
|
||||||
{diagnostics.length ? (
|
|
||||||
<DetailSection title={t("extensions.details.diagnostics")}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{diagnostics.map((diagnostic, index) => (
|
|
||||||
<div
|
|
||||||
key={`${diagnostic.code}:${index}`}
|
|
||||||
className="rounded-[14px] bg-amber-500/10 px-3 py-2.5"
|
|
||||||
>
|
|
||||||
<div className="text-[12px] font-medium text-amber-700 dark:text-amber-300">
|
|
||||||
{diagnostic.code}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-[12px] leading-5 text-muted-foreground">
|
|
||||||
{diagnostic.message}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DetailSection>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t border-border/45 bg-background/95 px-5 py-4">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={extension.trusted ? "outline" : "default"}
|
|
||||||
disabled={busy !== null || (!extension.trusted && !allGranted)}
|
|
||||||
onClick={() =>
|
|
||||||
void onAction(extension.trusted ? "untrust" : "trust", {
|
|
||||||
id: extension.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
<ShieldCheck className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
||||||
{extension.trusted
|
|
||||||
? t("extensions.revokeTrust")
|
|
||||||
: t("extensions.trust")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={busy !== null || !extension.trusted}
|
|
||||||
onClick={() =>
|
|
||||||
void onAction(extension.enabled ? "disable" : "enable", {
|
|
||||||
id: extension.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="rounded-full"
|
|
||||||
>
|
|
||||||
{extension.enabled
|
|
||||||
? t("extensions.disable")
|
|
||||||
: t("extensions.enable")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={busy !== null}
|
|
||||||
aria-label={t("extensions.uninstall")}
|
|
||||||
title={t("extensions.uninstall")}
|
|
||||||
onClick={() => setUninstallOpen(true)}
|
|
||||||
className="ml-auto h-8 w-8 rounded-full text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" aria-hidden />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
|
|
||||||
<AlertDialog open={uninstallOpen} onOpenChange={setUninstallOpen}>
|
|
||||||
<AlertDialogContent className="max-w-[26rem] rounded-[18px]">
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{t("extensions.uninstallTitle")}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{t("extensions.uninstallDescription", { name: extension.name })}
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>{t("extensions.cancel")}</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={() => void onAction("uninstall", { id: extension.id })}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{t("extensions.uninstall")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
||||||
import { CircleAlert, Download, Search } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
fetchExtensions,
|
|
||||||
runExtensionAction,
|
|
||||||
type ExtensionAction,
|
|
||||||
} from "@/lib/api";
|
|
||||||
import type { ExtensionDiagnosticInfo, ExtensionInfo } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
|
||||||
|
|
||||||
import { ExtensionDetailSheet } from "./ExtensionDetailSheet";
|
|
||||||
import {
|
|
||||||
EmptyState,
|
|
||||||
ExtensionMark,
|
|
||||||
filterExtensions,
|
|
||||||
LoadingState,
|
|
||||||
StatusBadge,
|
|
||||||
} from "./extension-ui";
|
|
||||||
|
|
||||||
type InstallKind = "git" | "local";
|
|
||||||
|
|
||||||
export function ExtensionsCatalog() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { token } = useClient();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
const [source, setSource] = useState("");
|
|
||||||
const [kind, setKind] = useState<InstallKind>("git");
|
|
||||||
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
|
|
||||||
const [diagnostics, setDiagnostics] = useState<ExtensionDiagnosticInfo[]>([]);
|
|
||||||
const [selected, setSelected] = useState<ExtensionInfo | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const payload = await fetchExtensions(token);
|
|
||||||
setExtensions(payload.extensions);
|
|
||||||
setDiagnostics(payload.diagnostics);
|
|
||||||
setError(null);
|
|
||||||
setSelected((current) =>
|
|
||||||
current
|
|
||||||
? payload.extensions.find((item) => item.id === current.id) ?? null
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
} catch (reason) {
|
|
||||||
setError((reason as Error).message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const mutate = useCallback(
|
|
||||||
async (
|
|
||||||
action: ExtensionAction,
|
|
||||||
values: Record<string, unknown>,
|
|
||||||
key: string,
|
|
||||||
) => {
|
|
||||||
setBusy(key);
|
|
||||||
try {
|
|
||||||
await runExtensionAction(token, action, values);
|
|
||||||
await refresh();
|
|
||||||
setError(null);
|
|
||||||
} catch (reason) {
|
|
||||||
setError((reason as Error).message);
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[refresh, token],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visible = useMemo(
|
|
||||||
() => filterExtensions(extensions, query),
|
|
||||||
[extensions, query],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<form
|
|
||||||
className="flex flex-col gap-2 sm:flex-row"
|
|
||||||
onSubmit={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const value = source.trim();
|
|
||||||
if (!value) return;
|
|
||||||
void mutate("install", { source: value, kind }, "install");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
value={kind}
|
|
||||||
onChange={(event) => setKind(event.target.value as InstallKind)}
|
|
||||||
aria-label={t("extensions.installKind")}
|
|
||||||
className="h-11 rounded-[14px] border border-border/55 bg-settings-surface px-3 text-[13px] text-foreground outline-none"
|
|
||||||
>
|
|
||||||
<option value="git">{t("extensions.source.git")}</option>
|
|
||||||
<option value="local">{t("extensions.source.local")}</option>
|
|
||||||
</select>
|
|
||||||
<Input
|
|
||||||
value={source}
|
|
||||||
onChange={(event) => setSource(event.target.value)}
|
|
||||||
placeholder={t(
|
|
||||||
kind === "git"
|
|
||||||
? "extensions.installGitPlaceholder"
|
|
||||||
: "extensions.installLocalPlaceholder",
|
|
||||||
)}
|
|
||||||
className="h-11 flex-1 rounded-[14px] border-border/55 bg-settings-surface text-[13px] shadow-none"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!source.trim() || busy === "install"}
|
|
||||||
className="h-11 rounded-[14px] px-4"
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" aria-hidden />
|
|
||||||
{t("extensions.installAction")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<label className="relative block">
|
|
||||||
<Search
|
|
||||||
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder={t("extensions.searchInstalled")}
|
|
||||||
className="h-11 rounded-[14px] border-border/55 bg-settings-surface pl-10 text-[13px] shadow-none"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{error ? (
|
|
||||||
<div className="rounded-[14px] bg-destructive/10 px-3.5 py-3 text-[13px] text-destructive">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ExtensionList
|
|
||||||
extensions={visible}
|
|
||||||
diagnostics={diagnostics}
|
|
||||||
loading={loading}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ExtensionDetailSheet
|
|
||||||
extension={selected}
|
|
||||||
diagnostics={diagnostics.filter(
|
|
||||||
(diagnostic) => diagnostic.extension_id === selected?.id,
|
|
||||||
)}
|
|
||||||
busy={busy}
|
|
||||||
open={selected !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSelected(null);
|
|
||||||
}}
|
|
||||||
onAction={(action, values) =>
|
|
||||||
mutate(action, values, `${action}:${selected?.id ?? ""}`)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExtensionList({
|
|
||||||
extensions,
|
|
||||||
diagnostics,
|
|
||||||
loading,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
extensions: ExtensionInfo[];
|
|
||||||
diagnostics: ExtensionDiagnosticInfo[];
|
|
||||||
loading: boolean;
|
|
||||||
onSelect: (extension: ExtensionInfo) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (loading) return <LoadingState />;
|
|
||||||
if (!extensions.length) {
|
|
||||||
return <EmptyState label={t("extensions.empty.installed")} />;
|
|
||||||
}
|
|
||||||
const diagnosticIds = new Set(diagnostics.map((item) => item.extension_id));
|
|
||||||
return (
|
|
||||||
<section className="overflow-hidden rounded-[18px] bg-settings-surface">
|
|
||||||
{extensions.map((extension, index) => (
|
|
||||||
<button
|
|
||||||
key={extension.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSelect(extension)}
|
|
||||||
className={cn(
|
|
||||||
"group flex w-full min-w-0 items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/35",
|
|
||||||
index > 0 && "border-t border-border/40",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ExtensionMark />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
|
||||||
<h3 className="truncate text-[14px] font-medium text-foreground">
|
|
||||||
{extension.name}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 truncate text-[12px] text-muted-foreground">
|
|
||||||
{extension.description || extension.id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{diagnosticIds.has(extension.id) ? (
|
|
||||||
<CircleAlert
|
|
||||||
className="h-4 w-4 shrink-0 text-amber-500"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<StatusBadge extension={extension} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Loader2, PackageOpen } from "lucide-react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import type { ExtensionInfo } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function ExtensionMark({ large = false }: { large?: boolean }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex shrink-0 items-center justify-center rounded-[13px] bg-muted/65 text-muted-foreground",
|
|
||||||
large ? "h-12 w-12" : "h-10 w-10",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PackageOpen
|
|
||||||
className={large ? "h-5 w-5" : "h-4 w-4"}
|
|
||||||
strokeWidth={1.8}
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StatusBadge({ extension }: { extension: ExtensionInfo }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [key, tone] = extension.active
|
|
||||||
? ["active", "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"]
|
|
||||||
: !extension.enabled
|
|
||||||
? ["disabled", "bg-muted text-muted-foreground"]
|
|
||||||
: !extension.trusted
|
|
||||||
? ["untrusted", "bg-amber-500/10 text-amber-700 dark:text-amber-300"]
|
|
||||||
: ["inactive", "bg-muted text-muted-foreground"];
|
|
||||||
return (
|
|
||||||
<span className={cn("shrink-0 rounded-full px-2 py-1 text-[11px] font-medium", tone)}>
|
|
||||||
{t(`extensions.status.${key}`)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DetailSection({
|
|
||||||
title,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-[12px] font-medium text-muted-foreground">{title}</h3>
|
|
||||||
{children}
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NamedItems({
|
|
||||||
title,
|
|
||||||
rows,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
rows: Array<{ name: string; meta: string }>;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<DetailSection title={title}>
|
|
||||||
{rows.length ? (
|
|
||||||
<div className="divide-y divide-border/35 overflow-hidden rounded-[14px] bg-muted/30">
|
|
||||||
{rows.map((row, index) => (
|
|
||||||
<div key={`${row.meta}:${row.name}:${index}`} className="flex gap-3 px-3 py-2.5">
|
|
||||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
|
||||||
{row.name}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 text-[11px] text-muted-foreground">{row.meta}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-[13px] text-muted-foreground">{t("extensions.none")}</p>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MetaItem({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-[14px] bg-muted/35 px-3 py-2.5">
|
|
||||||
<dt className="text-[11px] text-muted-foreground">{label}</dt>
|
|
||||||
<dd className="mt-0.5 truncate text-[13px] text-foreground">{value}</dd>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DetailPill({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<span className="rounded-full bg-muted px-2 py-1 text-[11px] text-muted-foreground">
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LoadingState() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-48 items-center justify-center gap-2 text-[13px] text-muted-foreground">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
||||||
{t("extensions.loading")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyState({ label }: { label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-48 items-center justify-center text-[13px] text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterExtensions(
|
|
||||||
items: ExtensionInfo[],
|
|
||||||
query: string,
|
|
||||||
): ExtensionInfo[] {
|
|
||||||
const term = query.trim().toLowerCase();
|
|
||||||
if (!term) return items;
|
|
||||||
return items.filter((item) =>
|
|
||||||
[item.name, item.id, item.description].some((value) =>
|
|
||||||
value.toLowerCase().includes(term),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,7 @@ export const DEFAULT_SIDEBAR_STATE: SidebarStatePayload = {
|
|||||||
project_name_overrides: {},
|
project_name_overrides: {},
|
||||||
tags_by_key: {},
|
tags_by_key: {},
|
||||||
collapsed_groups: {},
|
collapsed_groups: {},
|
||||||
|
activity_seen_at_by_key: {},
|
||||||
view: {
|
view: {
|
||||||
density: "comfortable",
|
density: "comfortable",
|
||||||
show_previews: false,
|
show_previews: false,
|
||||||
@@ -94,6 +95,7 @@ export function normalizeSidebarState(raw: unknown): SidebarStatePayload {
|
|||||||
project_name_overrides: stringMap(value.project_name_overrides),
|
project_name_overrides: stringMap(value.project_name_overrides),
|
||||||
tags_by_key: tagsMap(value.tags_by_key),
|
tags_by_key: tagsMap(value.tags_by_key),
|
||||||
collapsed_groups: boolMap(value.collapsed_groups),
|
collapsed_groups: boolMap(value.collapsed_groups),
|
||||||
|
activity_seen_at_by_key: stringMap(value.activity_seen_at_by_key),
|
||||||
view: {
|
view: {
|
||||||
density,
|
density,
|
||||||
show_previews: Boolean(view.show_previews),
|
show_previews: Boolean(view.show_previews),
|
||||||
@@ -124,9 +126,24 @@ function pruneMissingSessions(
|
|||||||
archived_keys: filterKeys(state.archived_keys),
|
archived_keys: filterKeys(state.archived_keys),
|
||||||
title_overrides: filterMap(state.title_overrides),
|
title_overrides: filterMap(state.title_overrides),
|
||||||
tags_by_key: filterMap(state.tags_by_key),
|
tags_by_key: filterMap(state.tags_by_key),
|
||||||
|
activity_seen_at_by_key: filterMap(state.activity_seen_at_by_key),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function seedMissingActivitySeenAt(
|
||||||
|
state: SidebarStatePayload,
|
||||||
|
sessions: ChatSummary[],
|
||||||
|
): SidebarStatePayload {
|
||||||
|
const seenAt = { ...state.activity_seen_at_by_key };
|
||||||
|
let changed = false;
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (seenAt[session.key] || !session.updatedAt) continue;
|
||||||
|
seenAt[session.key] = session.updatedAt;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
return changed ? { ...state, activity_seen_at_by_key: seenAt } : state;
|
||||||
|
}
|
||||||
|
|
||||||
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
|
function sameState(a: SidebarStatePayload, b: SidebarStatePayload): boolean {
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
}
|
}
|
||||||
@@ -196,7 +213,10 @@ export function useSidebarState(
|
|||||||
|
|
||||||
const pruned = useMemo(() => {
|
const pruned = useMemo(() => {
|
||||||
if (!sessionsLoaded || loading) return state;
|
if (!sessionsLoaded || loading) return state;
|
||||||
return pruneMissingSessions(state, sessions);
|
return seedMissingActivitySeenAt(
|
||||||
|
pruneMissingSessions(state, sessions),
|
||||||
|
sessions,
|
||||||
|
);
|
||||||
}, [loading, sessions, sessionsLoaded, state]);
|
}, [loading, sessions, sessionsLoaded, state]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Automations",
|
"automations": "Automations",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills"
|
"title": "Skills"
|
||||||
},
|
}
|
||||||
"extensions": "Extensions"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Back to chat",
|
"backToChat": "Back to chat",
|
||||||
@@ -1262,51 +1261,5 @@
|
|||||||
"usePath": "Use Path",
|
"usePath": "Use Path",
|
||||||
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
"absolutePathRequired": "Enter an absolute folder path on this machine."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Back to chat",
|
|
||||||
"title": "Extensions",
|
|
||||||
"searchInstalled": "Search extensions",
|
|
||||||
"empty": {
|
|
||||||
"installed": "No extensions installed."
|
|
||||||
},
|
|
||||||
"installKind": "Installation source",
|
|
||||||
"source": {
|
|
||||||
"git": "Git repository",
|
|
||||||
"local": "Local folder"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/absolute/path/to/extension",
|
|
||||||
"installAction": "Install",
|
|
||||||
"details": {
|
|
||||||
"identity": "Identity",
|
|
||||||
"source": "Source",
|
|
||||||
"license": "License",
|
|
||||||
"homepage": "Homepage",
|
|
||||||
"dependencies": "Dependencies",
|
|
||||||
"permissions": "Permissions",
|
|
||||||
"diagnostics": "Diagnostics"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Granted",
|
|
||||||
"permissionPending": "Not granted",
|
|
||||||
"revokePermissions": "Revoke permissions",
|
|
||||||
"grantPermissions": "Grant permissions",
|
|
||||||
"noPermissions": "No host permissions requested.",
|
|
||||||
"revokeTrust": "Revoke trust",
|
|
||||||
"trust": "Trust",
|
|
||||||
"disable": "Disable",
|
|
||||||
"enable": "Enable",
|
|
||||||
"uninstall": "Uninstall",
|
|
||||||
"uninstallTitle": "Uninstall extension?",
|
|
||||||
"uninstallDescription": "This removes {{name}} and its installed files from nanobot.",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"status": {
|
|
||||||
"active": "Active",
|
|
||||||
"disabled": "Disabled",
|
|
||||||
"untrusted": "Untrusted",
|
|
||||||
"inactive": "Inactive"
|
|
||||||
},
|
|
||||||
"none": "None",
|
|
||||||
"loading": "Loading extensions…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Automatizaciones",
|
"automations": "Automatizaciones",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Habilidades"
|
"title": "Habilidades"
|
||||||
},
|
}
|
||||||
"extensions": "Extensiones"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Volver al chat",
|
"backToChat": "Volver al chat",
|
||||||
@@ -1249,51 +1248,5 @@
|
|||||||
"usePath": "Usar ruta",
|
"usePath": "Usar ruta",
|
||||||
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
|
"absolutePathRequired": "Introduce una ruta absoluta de carpeta en esta máquina."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Volver al chat",
|
|
||||||
"title": "Extensiones",
|
|
||||||
"searchInstalled": "Buscar extensiones",
|
|
||||||
"empty": {
|
|
||||||
"installed": "No hay extensiones instaladas."
|
|
||||||
},
|
|
||||||
"installKind": "Origen de instalación",
|
|
||||||
"source": {
|
|
||||||
"git": "Repositorio Git",
|
|
||||||
"local": "Carpeta local"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/ruta/absoluta/a/la/extensión",
|
|
||||||
"installAction": "Instalar",
|
|
||||||
"details": {
|
|
||||||
"identity": "Identidad",
|
|
||||||
"source": "Origen",
|
|
||||||
"license": "Licencia",
|
|
||||||
"homepage": "Página principal",
|
|
||||||
"dependencies": "Dependencias",
|
|
||||||
"permissions": "Permisos",
|
|
||||||
"diagnostics": "Diagnóstico"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Concedido",
|
|
||||||
"permissionPending": "No concedido",
|
|
||||||
"revokePermissions": "Revocar permisos",
|
|
||||||
"grantPermissions": "Conceder permisos",
|
|
||||||
"noPermissions": "No solicita permisos del sistema.",
|
|
||||||
"revokeTrust": "Revocar confianza",
|
|
||||||
"trust": "Confiar",
|
|
||||||
"disable": "Desactivar",
|
|
||||||
"enable": "Activar",
|
|
||||||
"uninstall": "Desinstalar",
|
|
||||||
"uninstallTitle": "¿Desinstalar la extensión?",
|
|
||||||
"uninstallDescription": "Esto elimina {{name}} y sus archivos instalados de nanobot.",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"status": {
|
|
||||||
"active": "Activa",
|
|
||||||
"disabled": "Desactivada",
|
|
||||||
"untrusted": "No confiable",
|
|
||||||
"inactive": "Inactiva"
|
|
||||||
},
|
|
||||||
"none": "Ninguno",
|
|
||||||
"loading": "Cargando extensiones…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Automatisations",
|
"automations": "Automatisations",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Compétences"
|
"title": "Compétences"
|
||||||
},
|
}
|
||||||
"extensions": "Extensions"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Retour au chat",
|
"backToChat": "Retour au chat",
|
||||||
@@ -1248,51 +1247,5 @@
|
|||||||
"usePath": "Utiliser le chemin",
|
"usePath": "Utiliser le chemin",
|
||||||
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
|
"absolutePathRequired": "Saisissez un chemin absolu de dossier sur cette machine."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Retour au chat",
|
|
||||||
"title": "Extensions",
|
|
||||||
"searchInstalled": "Rechercher des extensions",
|
|
||||||
"empty": {
|
|
||||||
"installed": "Aucune extension installée."
|
|
||||||
},
|
|
||||||
"installKind": "Source d’installation",
|
|
||||||
"source": {
|
|
||||||
"git": "Dépôt Git",
|
|
||||||
"local": "Dossier local"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/chemin/absolu/vers/extension",
|
|
||||||
"installAction": "Installer",
|
|
||||||
"details": {
|
|
||||||
"identity": "Identité",
|
|
||||||
"source": "Source",
|
|
||||||
"license": "Licence",
|
|
||||||
"homepage": "Page d’accueil",
|
|
||||||
"dependencies": "Dépendances",
|
|
||||||
"permissions": "Autorisations",
|
|
||||||
"diagnostics": "Diagnostics"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Accordée",
|
|
||||||
"permissionPending": "Non accordée",
|
|
||||||
"revokePermissions": "Révoquer les autorisations",
|
|
||||||
"grantPermissions": "Accorder les autorisations",
|
|
||||||
"noPermissions": "Aucune autorisation hôte demandée.",
|
|
||||||
"revokeTrust": "Révoquer la confiance",
|
|
||||||
"trust": "Faire confiance",
|
|
||||||
"disable": "Désactiver",
|
|
||||||
"enable": "Activer",
|
|
||||||
"uninstall": "Désinstaller",
|
|
||||||
"uninstallTitle": "Désinstaller l’extension ?",
|
|
||||||
"uninstallDescription": "Cela supprime {{name}} et ses fichiers installés de nanobot.",
|
|
||||||
"cancel": "Annuler",
|
|
||||||
"status": {
|
|
||||||
"active": "Active",
|
|
||||||
"disabled": "Désactivée",
|
|
||||||
"untrusted": "Non approuvée",
|
|
||||||
"inactive": "Inactive"
|
|
||||||
},
|
|
||||||
"none": "Aucun",
|
|
||||||
"loading": "Chargement des extensions…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Otomasi",
|
"automations": "Otomasi",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skill"
|
"title": "Skill"
|
||||||
},
|
}
|
||||||
"extensions": "Ekstensi"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Kembali ke chat",
|
"backToChat": "Kembali ke chat",
|
||||||
@@ -1248,51 +1247,5 @@
|
|||||||
"usePath": "Gunakan path",
|
"usePath": "Gunakan path",
|
||||||
"absolutePathRequired": "Masukkan path folder absolut di mesin ini."
|
"absolutePathRequired": "Masukkan path folder absolut di mesin ini."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Kembali ke chat",
|
|
||||||
"title": "Ekstensi",
|
|
||||||
"searchInstalled": "Cari ekstensi",
|
|
||||||
"empty": {
|
|
||||||
"installed": "Belum ada ekstensi terpasang."
|
|
||||||
},
|
|
||||||
"installKind": "Sumber instalasi",
|
|
||||||
"source": {
|
|
||||||
"git": "Repositori Git",
|
|
||||||
"local": "Folder lokal"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/jalur/absolut/ke/ekstensi",
|
|
||||||
"installAction": "Pasang",
|
|
||||||
"details": {
|
|
||||||
"identity": "Identitas",
|
|
||||||
"source": "Sumber",
|
|
||||||
"license": "Lisensi",
|
|
||||||
"homepage": "Beranda",
|
|
||||||
"dependencies": "Dependensi",
|
|
||||||
"permissions": "Izin",
|
|
||||||
"diagnostics": "Diagnostik"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Diberikan",
|
|
||||||
"permissionPending": "Belum diberikan",
|
|
||||||
"revokePermissions": "Cabut izin",
|
|
||||||
"grantPermissions": "Berikan izin",
|
|
||||||
"noPermissions": "Tidak meminta izin host.",
|
|
||||||
"revokeTrust": "Cabut kepercayaan",
|
|
||||||
"trust": "Percayai",
|
|
||||||
"disable": "Nonaktifkan",
|
|
||||||
"enable": "Aktifkan",
|
|
||||||
"uninstall": "Copot",
|
|
||||||
"uninstallTitle": "Copot ekstensi?",
|
|
||||||
"uninstallDescription": "Tindakan ini menghapus {{name}} dan berkas terpasangnya dari nanobot.",
|
|
||||||
"cancel": "Batal",
|
|
||||||
"status": {
|
|
||||||
"active": "Aktif",
|
|
||||||
"disabled": "Nonaktif",
|
|
||||||
"untrusted": "Belum dipercaya",
|
|
||||||
"inactive": "Tidak aktif"
|
|
||||||
},
|
|
||||||
"none": "Tidak ada",
|
|
||||||
"loading": "Memuat ekstensi…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "自動タスク",
|
"automations": "自動タスク",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "スキル"
|
"title": "スキル"
|
||||||
},
|
}
|
||||||
"extensions": "拡張機能"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "チャットに戻る",
|
"backToChat": "チャットに戻る",
|
||||||
@@ -1248,51 +1247,5 @@
|
|||||||
"usePath": "パスを使用",
|
"usePath": "パスを使用",
|
||||||
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
|
"absolutePathRequired": "このマシン上の絶対フォルダーパスを入力してください。"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "チャットに戻る",
|
|
||||||
"title": "拡張機能",
|
|
||||||
"searchInstalled": "拡張機能を検索",
|
|
||||||
"empty": {
|
|
||||||
"installed": "拡張機能はインストールされていません。"
|
|
||||||
},
|
|
||||||
"installKind": "インストール元",
|
|
||||||
"source": {
|
|
||||||
"git": "Git リポジトリ",
|
|
||||||
"local": "ローカルフォルダー"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/拡張機能への絶対パス",
|
|
||||||
"installAction": "インストール",
|
|
||||||
"details": {
|
|
||||||
"identity": "識別情報",
|
|
||||||
"source": "ソース",
|
|
||||||
"license": "ライセンス",
|
|
||||||
"homepage": "ホームページ",
|
|
||||||
"dependencies": "依存関係",
|
|
||||||
"permissions": "権限",
|
|
||||||
"diagnostics": "診断"
|
|
||||||
},
|
|
||||||
"permissionGranted": "許可済み",
|
|
||||||
"permissionPending": "未許可",
|
|
||||||
"revokePermissions": "権限を取り消す",
|
|
||||||
"grantPermissions": "権限を許可",
|
|
||||||
"noPermissions": "ホスト権限は要求されていません。",
|
|
||||||
"revokeTrust": "信頼を取り消す",
|
|
||||||
"trust": "信頼する",
|
|
||||||
"disable": "無効化",
|
|
||||||
"enable": "有効化",
|
|
||||||
"uninstall": "アンインストール",
|
|
||||||
"uninstallTitle": "拡張機能をアンインストールしますか?",
|
|
||||||
"uninstallDescription": "{{name}} とインストール済みファイルを nanobot から削除します。",
|
|
||||||
"cancel": "キャンセル",
|
|
||||||
"status": {
|
|
||||||
"active": "有効",
|
|
||||||
"disabled": "無効",
|
|
||||||
"untrusted": "未信頼",
|
|
||||||
"inactive": "停止中"
|
|
||||||
},
|
|
||||||
"none": "なし",
|
|
||||||
"loading": "拡張機能を読み込み中…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "자동화",
|
"automations": "자동화",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "스킬"
|
"title": "스킬"
|
||||||
},
|
}
|
||||||
"extensions": "확장 기능"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "채팅으로 돌아가기",
|
"backToChat": "채팅으로 돌아가기",
|
||||||
@@ -1248,51 +1247,5 @@
|
|||||||
"usePath": "경로 사용",
|
"usePath": "경로 사용",
|
||||||
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
|
"absolutePathRequired": "이 머신의 절대 폴더 경로를 입력하세요."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "채팅으로 돌아가기",
|
|
||||||
"title": "확장",
|
|
||||||
"searchInstalled": "확장 검색",
|
|
||||||
"empty": {
|
|
||||||
"installed": "설치된 확장이 없습니다."
|
|
||||||
},
|
|
||||||
"installKind": "설치 소스",
|
|
||||||
"source": {
|
|
||||||
"git": "Git 저장소",
|
|
||||||
"local": "로컬 폴더"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/확장/절대/경로",
|
|
||||||
"installAction": "설치",
|
|
||||||
"details": {
|
|
||||||
"identity": "식별 정보",
|
|
||||||
"source": "소스",
|
|
||||||
"license": "라이선스",
|
|
||||||
"homepage": "홈페이지",
|
|
||||||
"dependencies": "종속성",
|
|
||||||
"permissions": "권한",
|
|
||||||
"diagnostics": "진단"
|
|
||||||
},
|
|
||||||
"permissionGranted": "허용됨",
|
|
||||||
"permissionPending": "허용되지 않음",
|
|
||||||
"revokePermissions": "권한 취소",
|
|
||||||
"grantPermissions": "권한 허용",
|
|
||||||
"noPermissions": "호스트 권한을 요청하지 않습니다.",
|
|
||||||
"revokeTrust": "신뢰 취소",
|
|
||||||
"trust": "신뢰",
|
|
||||||
"disable": "비활성화",
|
|
||||||
"enable": "활성화",
|
|
||||||
"uninstall": "제거",
|
|
||||||
"uninstallTitle": "확장을 제거할까요?",
|
|
||||||
"uninstallDescription": "nanobot에서 {{name}} 및 설치된 파일을 제거합니다.",
|
|
||||||
"cancel": "취소",
|
|
||||||
"status": {
|
|
||||||
"active": "활성",
|
|
||||||
"disabled": "비활성",
|
|
||||||
"untrusted": "신뢰 안 함",
|
|
||||||
"inactive": "중지됨"
|
|
||||||
},
|
|
||||||
"none": "없음",
|
|
||||||
"loading": "확장 로드 중…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Automações",
|
"automations": "Automações",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills"
|
"title": "Skills"
|
||||||
},
|
}
|
||||||
"extensions": "Extensões"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Voltar para a conversa",
|
"backToChat": "Voltar para a conversa",
|
||||||
@@ -1262,51 +1261,5 @@
|
|||||||
"usePath": "Usar caminho",
|
"usePath": "Usar caminho",
|
||||||
"absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina."
|
"absolutePathRequired": "Informe um caminho absoluto de pasta nesta máquina."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Voltar ao chat",
|
|
||||||
"title": "Extensões",
|
|
||||||
"searchInstalled": "Buscar extensões",
|
|
||||||
"empty": {
|
|
||||||
"installed": "Nenhuma extensão instalada."
|
|
||||||
},
|
|
||||||
"installKind": "Origem da instalação",
|
|
||||||
"source": {
|
|
||||||
"git": "Repositório Git",
|
|
||||||
"local": "Pasta local"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/caminho/absoluto/para/extensão",
|
|
||||||
"installAction": "Instalar",
|
|
||||||
"details": {
|
|
||||||
"identity": "Identidade",
|
|
||||||
"source": "Origem",
|
|
||||||
"license": "Licença",
|
|
||||||
"homepage": "Página inicial",
|
|
||||||
"dependencies": "Dependências",
|
|
||||||
"permissions": "Permissões",
|
|
||||||
"diagnostics": "Diagnósticos"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Concedida",
|
|
||||||
"permissionPending": "Não concedida",
|
|
||||||
"revokePermissions": "Revogar permissões",
|
|
||||||
"grantPermissions": "Conceder permissões",
|
|
||||||
"noPermissions": "Nenhuma permissão do host solicitada.",
|
|
||||||
"revokeTrust": "Revogar confiança",
|
|
||||||
"trust": "Confiar",
|
|
||||||
"disable": "Desativar",
|
|
||||||
"enable": "Ativar",
|
|
||||||
"uninstall": "Desinstalar",
|
|
||||||
"uninstallTitle": "Desinstalar extensão?",
|
|
||||||
"uninstallDescription": "Isso remove {{name}} e seus arquivos instalados do nanobot.",
|
|
||||||
"cancel": "Cancelar",
|
|
||||||
"status": {
|
|
||||||
"active": "Ativa",
|
|
||||||
"disabled": "Desativada",
|
|
||||||
"untrusted": "Não confiável",
|
|
||||||
"inactive": "Inativa"
|
|
||||||
},
|
|
||||||
"none": "Nenhum",
|
|
||||||
"loading": "Carregando extensões…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,8 +58,7 @@
|
|||||||
"automations": "Tự động hóa",
|
"automations": "Tự động hóa",
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Kỹ năng"
|
"title": "Kỹ năng"
|
||||||
},
|
}
|
||||||
"extensions": "Tiện ích"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"backToChat": "Quay lại chat",
|
"backToChat": "Quay lại chat",
|
||||||
@@ -1248,51 +1247,5 @@
|
|||||||
"usePath": "Dùng đường dẫn",
|
"usePath": "Dùng đường dẫn",
|
||||||
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
|
"absolutePathRequired": "Nhập đường dẫn thư mục tuyệt đối trên máy này."
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"extensions": {
|
|
||||||
"backToChat": "Quay lại trò chuyện",
|
|
||||||
"title": "Tiện ích",
|
|
||||||
"searchInstalled": "Tìm tiện ích",
|
|
||||||
"empty": {
|
|
||||||
"installed": "Chưa cài tiện ích nào."
|
|
||||||
},
|
|
||||||
"installKind": "Nguồn cài đặt",
|
|
||||||
"source": {
|
|
||||||
"git": "Kho Git",
|
|
||||||
"local": "Thư mục cục bộ"
|
|
||||||
},
|
|
||||||
"installGitPlaceholder": "https://github.com/acme/extension.git",
|
|
||||||
"installLocalPlaceholder": "/đường/dẫn/tuyệt/đối/đến/tiện-ích",
|
|
||||||
"installAction": "Cài đặt",
|
|
||||||
"details": {
|
|
||||||
"identity": "Danh tính",
|
|
||||||
"source": "Nguồn",
|
|
||||||
"license": "Giấy phép",
|
|
||||||
"homepage": "Trang chủ",
|
|
||||||
"dependencies": "Phụ thuộc",
|
|
||||||
"permissions": "Quyền",
|
|
||||||
"diagnostics": "Chẩn đoán"
|
|
||||||
},
|
|
||||||
"permissionGranted": "Đã cấp",
|
|
||||||
"permissionPending": "Chưa cấp",
|
|
||||||
"revokePermissions": "Thu hồi quyền",
|
|
||||||
"grantPermissions": "Cấp quyền",
|
|
||||||
"noPermissions": "Không yêu cầu quyền máy chủ.",
|
|
||||||
"revokeTrust": "Thu hồi tin cậy",
|
|
||||||
"trust": "Tin cậy",
|
|
||||||
"disable": "Tắt",
|
|
||||||
"enable": "Bật",
|
|
||||||
"uninstall": "Gỡ cài đặt",
|
|
||||||
"uninstallTitle": "Gỡ tiện ích?",
|
|
||||||
"uninstallDescription": "Thao tác này xóa {{name}} và các tệp đã cài khỏi nanobot.",
|
|
||||||
"cancel": "Hủy",
|
|
||||||
"status": {
|
|
||||||
"active": "Đang hoạt động",
|
|
||||||
"disabled": "Đã tắt",
|
|
||||||
"untrusted": "Chưa tin cậy",
|
|
||||||
"inactive": "Không hoạt động"
|
|
||||||
},
|
|
||||||
"none": "Không có",
|
|
||||||
"loading": "Đang tải tiện ích…"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user