Compare commits

..
Author SHA1 Message Date
Xubin Ren f61537a8c5 fix(tools): preserve computer use runtime state 2026-08-09 14:08:10 +09:00
Xubin Ren 612e714479 feat(webui): add computer use controls 2026-08-09 02:40:17 +09:00
Xubin Ren a739185740 fix(tools): bound computer use runtime state 2026-08-09 01:27:22 +09:00
Xubin Ren 9859e02215 fix(tools): harden computer use integration 2026-08-09 01:12:13 +09:00
95160a304d feat(tools): add model-agnostic computer use (computer_use + browser tools)
Adds two opt-in agent tools for controlling a computer:
- computer_use: pixel-based (screenshot + mouse/keyboard) via a desktop
  (pyautogui) or browser (playwright) backend.
- browser: DOM/accessibility-based web automation (act by element ref),
  reliable across ANY tool-calling model, not just vision/CU-trained ones.

Core enabler in the runner: a tool may return image content blocks, which
are split out and delivered to the model as a follow-up user message
(_split_tool_result_media), so screenshots reach any vision provider
(e.g. via OpenRouter/openai-compat) without provider-specific code.

Both tools are OFF by default (tools.computerUse.enable / tools.browser.enable),
are not exposed to subagents, and the browser tool supports an allowed_domains
allowlist. Heavy deps (pyautogui/pillow/playwright) are an optional [computer-use] extra.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-09 01:12:13 +09:00
Xubin Ren af52fbcbc4 fix(webui): emphasize temporary chat expiry 2026-08-08 23:20:59 +08:00
Xubin Ren 92eb91338a fix(webui): label temporary chat guidance 2026-08-08 23:20:59 +08:00
Xubin Ren c410ea444c fix(agent): stop session-owned exec processes 2026-08-08 23:20:59 +08:00
chengyongruandXubin Ren 8e04f12720 fix(webui): name temporary chats from first message 2026-08-08 23:20:59 +08:00
chengyongruandXubin Ren 516ae11c33 fix(webui): simplify temporary chat closing 2026-08-08 23:20:59 +08:00
chengyongruandXubin Ren 656e0d606b fix(webui): tighten temporary chat close action 2026-08-08 23:20:59 +08:00
chengyongruandXubin Ren 75e333a3c5 fix(webui): derive temporary chats from session policy 2026-08-08 23:20:59 +08:00
chengyongruandXubin Ren a5bc3bfbb9 fix(webui): complete temporary chat mode 2026-08-08 23:20:59 +08:00
Xubin Ren c9a6145878 feat(webui): add temporary chat mode 2026-08-08 23:20:59 +08:00
chengyongruandchengyongru 113e8d67ad refactor: remove verified dead code 2026-08-08 21:10:34 +08:00
chengyongruandGitHub 4e063f5695 fix(webui): prevent image hover clipping (#5294) 2026-08-08 18:05:00 +08:00
118 changed files with 5269 additions and 1455 deletions
+3 -2
View File
@@ -125,6 +125,7 @@ Important files:
| Shell execution | `nanobot/agent/tools/shell.py` |
| Filesystem tools | `nanobot/agent/tools/filesystem.py` |
| Web search/fetch | `nanobot/agent/tools/web.py` |
| Browser and computer use | `nanobot/agent/tools/browser_tool.py`, `nanobot/agent/tools/computer_use.py` |
| MCP tools | `nanobot/agent/tools/mcp.py` |
| Cron | `nanobot/agent/tools/cron.py`, `nanobot/cron/` |
| Image generation | `nanobot/agent/tools/image_generation.py` |
@@ -188,7 +189,7 @@ Security-sensitive code paths include:
|---|---|
| Workspace scope | `nanobot/security/workspace_access.py`, `nanobot/security/workspace_policy.py` |
| Shell sandboxing | `nanobot/agent/tools/shell.py` |
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py` |
| SSRF/network checks | `nanobot/security/network.py`, `nanobot/agent/tools/web.py`, `nanobot/agent/tools/computer_use_backends/browser_playwright.py` |
| PTH guard and CLI startup security | `nanobot/security/` and CLI entrypoints |
| Channel access control | channel config in `nanobot/channels/*.py` |
@@ -202,7 +203,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skills under `<workspace>/skills/`, Agent Plugins v1 under `<workspace>/plugins/`, or built-in skills under `nanobot/skills/` |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |
Prefer existing registry/discovery patterns over ad hoc wiring.
+57 -30
View File
@@ -42,6 +42,7 @@ the focused guides first and come back here for exact fields and defaults.
| Add fallback chains | [Model Fallbacks](#model-fallbacks) |
| Configure voice transcription | [Transcription Settings](#transcription-settings) |
| Tune channel defaults | [Channel Settings](#channel-settings) |
| Enable browser or desktop control | [Browser and Computer Use](#browser-and-computer-use) |
| Configure web search and fetch | [Web Tools](#web-tools) |
| Enable image generation | [Image Generation](#image-generation) |
| Add MCP servers | [MCP](#mcp-model-context-protocol) |
@@ -1670,6 +1671,62 @@ When a channel `send()` raises, nanobot retries at the channel-manager layer. By
>
> If a channel is completely unreachable, nanobot cannot notify the user through that same channel. Watch logs for `Failed to send to {channel} after N attempts` to spot persistent delivery failures.
## Browser and Computer Use
Browser and desktop control are optional and disabled by default. Install their runtime first:
```bash
pip install 'nanobot-ai[computer-use]'
playwright install chromium
```
For normal web interaction, prefer the DOM-based `browser` tool. It gives the model numbered
element references and works without vision. Use `computer_use` when the model must see and act
on pixels; its `desktop` backend controls the real local machine, while its `browser` backend
controls an isolated Playwright page.
```json
{
"tools": {
"browser": {
"enable": true,
"allowedDomains": ["example.com"]
},
"computerUse": {
"enable": false,
"backend": "desktop"
}
}
}
```
| Option | Default | Description |
|---|---|---|
| `tools.browser.enable` | `false` | Register the DOM-based `browser` tool |
| `tools.browser.allowedDomains` | `[]` | Optional top-level navigation allowlist; entries include subdomains |
| `tools.browser.includeScreenshot` | `false` | Attach a screenshot after browser actions |
| `tools.browser.maxSessions` | `8` | Maximum retained browser sessions; least-recently-used state is closed first |
| `tools.computerUse.enable` | `false` | Register pixel-based `computer_use` |
| `tools.computerUse.backend` | `"desktop"` | `"desktop"` or `"browser"` |
| `tools.computerUse.allowedDomains` | `[]` | Navigation allowlist for the browser backend |
| `tools.computerUse.targetWidth` / `targetHeight` | `1280` / `800` | Maximum screenshot dimensions exposed to the model |
| `tools.computerUse.maxSessions` | `8` | Maximum retained sessions for the browser backend |
Each nanobot session gets separate browser state. Browser HTTP and WebSocket traffic passes
through the shared SSRF policy; local, private, link-local, and metadata targets are blocked
unless explicitly permitted with `tools.ssrfWhitelist`. When `maxSessions` is reached, the
least-recently-used browser state is closed. `file:` URLs are not accepted.
> [!IMPORTANT]
> Browser URL checks are defense in depth, not an egress sandbox: Chromium performs its own DNS
> resolution after validation. Use OS/container network isolation when browsing hostile pages.
> [!WARNING]
> The desktop backend can click, type, and change state outside the workspace. Enabling it is an
> explicit trust decision: use a trusted model and input source, and run nanobot in a disposable
> OS account or VM when unattended. The workspace restriction is not an OS sandbox. Desktop text
> input supports ASCII key events; use the browser backend when Unicode text input is required.
## Web Tools
nanobot incorporates basic tools for accessing the web. These include searching via APIs, and fetching arbitrary web pages in Markdown format. They are enabled by default, and can be configured in `~/.nanobot/config.json` under `tools.web`.
@@ -2306,36 +2363,6 @@ Disabled skills are excluded from the main agent's skill summary, from always-on
|--------|---------|-------------|
| `agents.defaults.disabledSkills` | `[]` | List of skill directory names to exclude from loading. Applies to both built-in skills and workspace skills. |
### Agent Plugins v1 skills
nanobot also discovers portable [Agent Plugins](https://agent-plugins.org/) placed under
`<workspace>/plugins/<plugin>/`. A supported package has a root `plugin.json` that targets
Agent Plugins v1 and one or more direct-child skills:
```text
plugins/
└── release-tools/
├── plugin.json
└── skills/
└── release-notes/
└── SKILL.md
```
Plugin skills use the same progressive loading and `$skill-name` invocation as workspace
skills. A workspace skill wins when it has the same name as a plugin skill; plugin skills win
over built-in skills. Invalid manifests, invalid Agent Skills, nested skill directories, and
paths that resolve outside the plugin root are ignored.
This initial compatibility layer loads the portable `skills/` component only. Agent Plugins
`mcp.json` is not started automatically: local MCP servers execute third-party processes and
need an explicit trust and approval flow. Configure a reviewed MCP server through **Apps** or
`tools.mcpServers` for now.
CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through
its catalog adapter, then writes a skills-only Agent Plugin under `<workspace>/plugins/`; updates
refresh that package and uninstall removes it. The external executable remains managed by the
CLI Apps installer rather than by the Agent Plugins manifest.
## Tool Hint Max Length
Tool hints are the short progress messages shown when the agent calls tools (e.g. `$ cd …/project && npm test`). By default, these are truncated at 40 characters, which can make long commands hard to read.
-210
View File
@@ -1,210 +0,0 @@
"""Discover portable Agent Plugins from the agent workspace."""
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
import yaml
from loguru import logger
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
_PLUGIN_NAME = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
_SKILL_NAME = re.compile(r"^(?!.*--)[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
_SKILL_FRONTMATTER = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_MANIFEST_FIELDS = {
"$schema",
"name",
"version",
"description",
"author",
"homepage",
"repository",
"license",
"keywords",
"extensions",
}
_STRING_FIELDS = {"version", "description", "homepage", "repository", "license"}
_AUTHOR_FIELDS = {"name", "email", "url"}
@dataclass(frozen=True)
class AgentPluginSkill:
"""One skill supplied by a valid Agent Plugins v1 package."""
name: str
path: Path
plugin: str
def discover_agent_plugin_skills(workspace: Path) -> list[AgentPluginSkill]:
"""Discover direct-child skills under ``<workspace>/plugins/*``.
Agent Plugins does not prescribe an install location. nanobot uses the
workspace ``plugins`` directory so packages stay explicit and portable
with the rest of the agent workspace.
"""
workspace = workspace.expanduser().resolve()
plugins_root = workspace / "plugins"
if not plugins_root.is_dir():
return []
try:
resolved_plugins_root = plugins_root.resolve(strict=True)
except OSError:
return []
if not resolved_plugins_root.is_relative_to(workspace):
logger.warning("Ignoring Agent Plugins directory outside the workspace")
return []
try:
candidates = sorted(plugins_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugins directory: {}", exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
plugin_root = _contained_directory(candidate, resolved_plugins_root)
if plugin_root is None:
continue
plugin_name = _load_manifest_name(plugin_root)
if plugin_name is None:
continue
skills.extend(_discover_plugin_skills(plugin_name, plugin_root))
return skills
def _load_manifest_name(plugin_root: Path) -> str | None:
manifest = _contained_file(plugin_root / "plugin.json", plugin_root)
if manifest is None:
return None
try:
value = cast(object, json.loads(manifest.read_text(encoding="utf-8")))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
logger.warning("Ignoring invalid Agent Plugin manifest '{}': {}", manifest, exc)
return None
if not isinstance(value, dict):
logger.warning("Ignoring Agent Plugin manifest '{}': expected a JSON object", manifest)
return None
payload = cast(dict[str, Any], value)
if payload.get("$schema") != AGENT_PLUGIN_SCHEMA:
return None
name = payload.get("name")
if (
not isinstance(name, str)
or len(name) > 64
or _PLUGIN_NAME.fullmatch(name) is None
):
logger.warning("Ignoring Agent Plugin manifest '{}': invalid name", manifest)
return None
if not _valid_optional_fields(payload):
logger.warning("Ignoring Agent Plugin manifest '{}': invalid metadata", manifest)
return None
for field in payload.keys() - _MANIFEST_FIELDS:
logger.warning("Ignoring unknown Agent Plugin manifest field '{}' in '{}'", field, manifest)
if "extensions" in payload and not isinstance(payload["extensions"], dict):
logger.warning("Ignoring non-object Agent Plugin extensions in '{}'", manifest)
return name
def _valid_optional_fields(payload: dict[str, Any]) -> bool:
if any(field in payload and not isinstance(payload[field], str) for field in _STRING_FIELDS):
return False
keywords = payload.get("keywords")
if "keywords" in payload and (
not isinstance(keywords, list)
or not all(isinstance(keyword, str) for keyword in cast(list[object], keywords))
):
return False
author = payload.get("author")
if "author" not in payload:
return True
if not isinstance(author, dict):
return False
author_payload = cast(dict[str, object], author)
return not (author_payload.keys() - _AUTHOR_FIELDS) and all(
isinstance(value, str) for value in author_payload.values()
)
def _discover_plugin_skills(plugin_name: str, plugin_root: Path) -> list[AgentPluginSkill]:
skills_root = plugin_root / "skills"
if not skills_root.exists():
return []
resolved_skills_root = _contained_directory(skills_root, plugin_root)
if resolved_skills_root is None:
logger.warning("Ignoring invalid skills component in Agent Plugin '{}'", plugin_name)
return []
try:
candidates = sorted(skills_root.iterdir(), key=lambda path: path.name)
except OSError as exc:
logger.warning("Could not inspect Agent Plugin '{}' skills: {}", plugin_name, exc)
return []
skills: list[AgentPluginSkill] = []
for candidate in candidates:
skill_root = _contained_directory(candidate, resolved_skills_root)
if skill_root is None:
continue
skill_file = _contained_file(skill_root / "SKILL.md", plugin_root)
if skill_file is None or not _valid_skill(skill_file, candidate.name, plugin_name):
continue
skills.append(
AgentPluginSkill(name=candidate.name, path=skill_file, plugin=plugin_name)
)
return skills
def _valid_skill(path: Path, directory_name: str, plugin_name: str) -> bool:
try:
content = path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
return False
match = _SKILL_FRONTMATTER.match(content)
if match is None:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
try:
metadata = cast(object, yaml.safe_load(match.group(1)))
except yaml.YAMLError:
metadata = None
if not isinstance(metadata, dict):
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid frontmatter", plugin_name, directory_name)
return False
payload = cast(dict[object, object], metadata)
name = payload.get("name")
description = payload.get("description")
valid = (
name == directory_name
and isinstance(name, str)
and len(name) <= 64
and _SKILL_NAME.fullmatch(name) is not None
and isinstance(description, str)
and 1 <= len(description.strip()) <= 1024
)
if not valid:
logger.warning("Ignoring Agent Plugin '{}' skill '{}': invalid metadata", plugin_name, directory_name)
return valid
def _contained_directory(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _contained_file(path: Path, root: Path) -> Path | None:
try:
resolved = path.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_file() and resolved.is_relative_to(root) else None
-7
View File
@@ -140,10 +140,3 @@ class AutomationTurnCoordinator:
if pending_id:
pending_ids.add(pending_id)
return pending_ids
async def publish_next_deferred(self, session_key: str) -> bool:
return await publish_next_deferred_turn(
deferred_queues=self.deferred_queues,
publish_inbound=self._publish_inbound,
session_key=session_key,
)
+12 -1
View File
@@ -13,7 +13,11 @@ from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools import sessions as session_tools
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
)
from nanobot.runtime_context import (
RUNTIME_CONTEXT_END,
RUNTIME_CONTEXT_MESSAGE_META,
@@ -47,6 +51,9 @@ async def close_mcp(state: Any) -> None:
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
await state.discard_session(msg.session_key)
return True
for handler in (
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
@@ -79,6 +86,7 @@ class ContextBuilder:
channel: str | None = None,
session_summary: str | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
@@ -93,6 +101,7 @@ class ContextBuilder:
parts.append(render_template("agent/tool_contract.md"))
if include_memory:
memory = self.memory.read_memory()
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
@@ -219,6 +228,7 @@ class ContextBuilder:
session_summary: str | None = None,
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
workspace: Path | None = None,
include_memory: bool = True,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
@@ -238,6 +248,7 @@ class ContextBuilder:
channel=channel,
session_summary=session_summary,
workspace=root,
include_memory=include_memory,
include_memory_recent_history=include_memory_recent_history,
session_key=session_key,
unified_session=unified_session,
+44
View File
@@ -33,6 +33,11 @@ COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
VISUAL_TOOLS = frozenset({"browser", "computer_use"})
STALE_SCREENSHOT_PLACEHOLDER = {
"type": "text",
"text": "[Earlier screenshot omitted; use the latest screenshot from this tool.]",
}
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
@@ -41,6 +46,12 @@ PLACEHOLDER_TEXTS = frozenset({
})
def _is_image_block(value: object) -> bool:
if not isinstance(value, dict):
return False
return cast(dict[str, Any], value).get("type") in {"image_url", "input_image"}
def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""Whether a persisted OpenAI-style tool_call carries a usable name.
@@ -84,6 +95,10 @@ class ContextGovernor:
updated = self.drop_orphan_tool_results(updated)
updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated)
updated = self.drop_stale_visual_tool_images(
updated,
start_index=config.inflight_start_index,
)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
updated = self.snip_history(config, updated)
updated = self.drop_orphan_tool_results(updated)
@@ -326,6 +341,35 @@ class ContextGovernor:
updated[idx]["content"] = normalized
return updated
@staticmethod
def drop_stale_visual_tool_images(
messages: list[dict[str, Any]],
*,
start_index: int,
) -> list[dict[str, Any]]:
"""Keep only the latest in-flight screenshot from each visual tool."""
seen: set[str] = set()
updated = messages
for idx in range(len(messages) - 1, start_index - 1, -1):
message = messages[idx]
name = str(message.get("name") or "")
content = message.get("content")
if message.get("role") != "tool" or name not in VISUAL_TOOLS:
continue
if not isinstance(content, list):
continue
content_blocks = cast(list[object], content)
blocks = [block for block in content_blocks if not _is_image_block(block)]
if len(blocks) == len(content_blocks):
continue
if name not in seen:
seen.add(name)
continue
if updated is messages:
updated = [dict(item) for item in messages]
updated[idx]["content"] = [dict(STALE_SCREENSHOT_PLACEHOLDER), *blocks]
return updated
def compact_inflight_overflow(
self,
config: ContextGovernanceConfig,
+52 -11
View File
@@ -398,6 +398,7 @@ class AgentLoop:
self._mcp_connecting = False
self._runtime_context_providers: list[RuntimeContextProvider] = []
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
self._discarding_sessions: set[str] = set()
self._background_tasks: set[asyncio.Task[Any]] = set()
self._close_mcp_lock = asyncio.Lock()
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
@@ -721,6 +722,7 @@ class AgentLoop:
session_summary=ctx.pending_summary,
workspace=scope.project_path,
runtime_context_blocks=ctx.runtime_context_blocks,
include_memory=ctx.session.policy.persist,
include_memory_recent_history=not ctx.ephemeral,
session_key=ctx.session.key,
unified_session=self._unified_session,
@@ -786,9 +788,9 @@ class AgentLoop:
logger.warning("Command '{}' matched but dispatch returned None", raw)
async def _cancel_active_tasks(self, key: str) -> int:
"""Cancel and await all active tasks and subagents for *key*.
"""Cancel and await all active work for *key*.
Returns the total number of cancelled tasks + subagents.
Returns the total number of cancelled tasks, subagents, and exec sessions.
"""
tasks = tuple(self._active_tasks.pop(key, set()))
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
@@ -796,7 +798,17 @@ class AgentLoop:
with suppress(asyncio.CancelledError, Exception):
await t
sub_cancelled = await self.subagents.cancel_by_session(key)
return cancelled + sub_cancelled
exec_cancelled = await self._exec_session_manager.terminate_by_owner(key)
return cancelled + sub_cancelled + exec_cancelled
async def discard_session(self, key: str) -> None:
"""Stop active work for *key* and forget its cached session."""
self._discarding_sessions.add(key)
try:
self.sessions.invalidate(key)
await self._cancel_active_tasks(key)
finally:
self._discarding_sessions.discard(key)
def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections."""
@@ -1161,6 +1173,11 @@ class AgentLoop:
effective_key = self._effective_session_key(msg)
if await agent_context.handle_runtime_control(self, msg, self.tools):
continue
if (
msg.require_existing_session
and self.sessions.get_cached(effective_key) is None
):
continue
if self.commands.is_priority(raw):
await self._dispatch_command_inline(
msg, effective_key, raw,
@@ -1279,6 +1296,8 @@ class AgentLoop:
# _emit_checkpoint during tool execution; materializing
# it into session history now makes it visible in the
# next conversation turn.
if session_key in self._discarding_sessions:
raise
try:
key = self._effective_session_key(msg)
session = self.sessions.get_or_create(key)
@@ -1378,6 +1397,7 @@ class AgentLoop:
cleanup_steps = (
self.subagents.close,
self._exec_session_manager.close_all,
*(() if not hasattr(self, "tools") else (self.tools.close,)),
lambda: agent_context.close_mcp(self),
)
for cleanup in cleanup_steps:
@@ -1556,6 +1576,7 @@ class AgentLoop:
had_injections: bool,
streamed_content: bool,
*,
log_content: bool = True,
turn_latency_ms: int | None = None,
) -> OutboundMessage | None:
"""Assemble the final outbound message from turn results."""
@@ -1564,8 +1585,11 @@ class AgentLoop:
if not had_injections or stop_reason == "empty_final_response":
return None
if log_content:
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
else:
logger.info("Response to {}:{}: [content hidden]", msg.channel, msg.sender_id)
event = None
meta = dict(msg.metadata or {})
@@ -1594,17 +1618,33 @@ class AgentLoop:
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
msg = ctx.msg
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
if ctx.kind is TurnKind.SYSTEM:
logger.info("Processing system message from {}", msg.sender_id)
else:
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
# Session is already fetched by the caller (_process_message) but
# ensure it exists in case this handler is invoked independently.
if ctx.session is None:
if msg.require_existing_session:
ctx.session = self.sessions.get_cached(ctx.session_key)
if ctx.session is None:
raise RuntimeError("required session is not active")
else:
ctx.session = self.sessions.get_or_create(ctx.session_key)
session = ctx.session
ctx.ephemeral = ctx.ephemeral or not session.policy.persist
tools = ctx.tools or self.tools
if session.policy.disabled_tools:
restricted = ToolRegistry()
for name in tools.tool_names:
tool = tools.get(name)
if name not in session.policy.disabled_tools and tool:
restricted.register(tool)
tools = restricted
ctx.tools = tools
if ctx.kind is TurnKind.SYSTEM:
logger.info("Processing system message from {}", msg.sender_id)
elif session.policy.log_content:
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
else:
logger.info("Processing message from {}:{}: [content hidden]", msg.channel, msg.sender_id)
self._remember_unified_session_route(
session,
msg,
@@ -1907,6 +1947,7 @@ class AgentLoop:
ctx.stop_reason,
ctx.had_injections,
ctx.streamed_content,
log_content=ctx.require_session().policy.log_content,
turn_latency_ms=ctx.turn_latency_ms,
)
if ctx.ephemeral and ctx.outbound is not None:
+8 -28
View File
@@ -9,8 +9,6 @@ from typing import Any, cast
import yaml
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
# Default builtin skills directory (relative to this file)
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
@@ -35,7 +33,6 @@ class SkillsLoader:
self.workspace_skills = workspace / "skills"
self.builtin_skills = builtin_skills_dir or BUILTIN_SKILLS_DIR
self.disabled_skills = disabled_skills or set()
self.plugin_skills = discover_agent_plugin_skills(workspace)
def _skill_entries_from_dir(self, base: Path, source: str, *, skip_names: set[str] | None = None) -> list[dict[str, str]]:
if not base.exists():
@@ -63,24 +60,11 @@ class SkillsLoader:
Returns:
List of skill info dicts with 'name', 'path', 'source'.
"""
self.plugin_skills = discover_agent_plugin_skills(self.workspace)
skills = self._skill_entries_from_dir(self.workspace_skills, "workspace")
seen_names = {entry["name"] for entry in skills}
for plugin_skill in self.plugin_skills:
if plugin_skill.name in seen_names:
continue
skills.append(
{
"name": plugin_skill.name,
"path": str(plugin_skill.path),
"source": "plugin",
"plugin": plugin_skill.plugin,
}
)
seen_names.add(plugin_skill.name)
workspace_names = {entry["name"] for entry in skills}
if self.builtin_skills and self.builtin_skills.exists():
skills.extend(
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=seen_names)
self._skill_entries_from_dir(self.builtin_skills, "builtin", skip_names=workspace_names)
)
if self.disabled_skills:
@@ -100,16 +84,13 @@ class SkillsLoader:
Returns:
Skill content or None if not found.
"""
workspace_path = self.workspace_skills / name / "SKILL.md"
if workspace_path.exists():
return workspace_path.read_text(encoding="utf-8")
for plugin_skill in self.plugin_skills:
if plugin_skill.name == name:
return plugin_skill.path.read_text(encoding="utf-8")
roots = [self.workspace_skills]
if self.builtin_skills:
builtin_path = self.builtin_skills / name / "SKILL.md"
if builtin_path.exists():
return builtin_path.read_text(encoding="utf-8")
roots.append(self.builtin_skills)
for root in roots:
path = root / name / "SKILL.md"
if path.exists():
return path.read_text(encoding="utf-8")
return None
def load_skills_for_context(self, skill_names: list[str]) -> str:
@@ -164,7 +145,6 @@ class SkillsLoader:
sections: list[str] = []
groups = (
("Workspace skills", "workspace", self.workspace_skills),
("Agent Plugin skills", "plugin", self.workspace / "plugins"),
("Built-in skills", "builtin", self.builtin_skills),
)
for label, source, root in groups:
+4
View File
@@ -220,6 +220,10 @@ class Tool(ABC):
"""Return optional per-turn prompt context owned by this tool."""
return None
async def close(self) -> None:
"""Release resources owned by the tool. Safe to call repeatedly."""
return None
@abstractmethod
async def execute(self, **kwargs: Any) -> Any:
"""Run the tool; return content, or ``ToolResult.error(...)`` for failures."""
+286
View File
@@ -0,0 +1,286 @@
"""DOM-based browser automation by element reference."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config_base import Base
from nanobot.utils.helpers import build_image_content_blocks
_ACTIONS = [
"navigate",
"snapshot",
"click",
"type",
"select",
"scroll",
"key",
"back",
"read_text",
]
class BrowserToolConfig(Base):
"""browser (DOM) tool configuration."""
enable: bool = False
start_url: str = "about:blank"
headless: bool = True
width: int = Field(default=1280, ge=320, le=4096)
height: int = Field(default=800, ge=240, le=4096)
allowed_domains: list[str] = Field(default_factory=list)
include_screenshot: bool = False
max_elements: int = Field(default=200, ge=1, le=1000)
max_sessions: int = Field(default=8, ge=1, le=64)
def _format_elements(elements: list[dict[str, Any]]) -> str:
if not elements:
return "Interactive elements: (none found — try scrolling or read_text)"
lines: list[str] = []
for e in elements:
tag = str(e.get("tag") or "")
typ = str(e.get("type") or "")
label = tag + (f"[{typ}]" if typ else "")
line = f"[{e.get('ref')}] {label}"
name = str(e.get("name") or "").strip()
if name:
line += f' "{name}"'
href = str(e.get("href") or "")
if href and tag == "a":
line += f" -> {href[:60]}"
lines.append(line)
return "Interactive elements (act with the [ref] number):\n" + "\n".join(lines)
@tool_parameters(
tool_parameters_schema(
action=StringSchema("The action to perform.", enum=_ACTIONS),
ref=IntegerSchema(
description="Element ref number from the latest snapshot (click/type/select).",
minimum=1,
nullable=True,
),
text=StringSchema(
"Text to type (action=type) or key/combo like 'Enter'/'ctrl+a' (action=key).",
nullable=True,
),
url=StringSchema("URL to open (action=navigate).", nullable=True),
value=StringSchema("Option value/label to choose (action=select).", nullable=True),
submit=BooleanSchema(description="Press Enter after typing (action=type).", nullable=True),
scroll_direction=StringSchema(
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
),
scroll_amount=IntegerSchema(
description="Scroll clicks (action=scroll).",
minimum=1,
maximum=100,
nullable=True,
),
required=["action"],
)
)
class BrowserTool(Tool):
"""Browse and act on web pages by element ref (DOM-based, works with any model)."""
_scopes = {"core"}
name = "browser" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Control a web browser by acting on page elements by their [ref] number. "
"Each call returns the current page URL plus a fresh numbered list of the page's "
"interactive elements; pick a [ref] to click/type/select — no pixel coordinates "
"needed. A page may already be open: call 'snapshot' FIRST to see it. Only use "
"'navigate' for a specific URL you were explicitly given — never guess a URL. "
"Move between pages by clicking links/buttons via their [ref]. Use 'read_text' to "
"read page text. Re-read the element list after each action; refs are reassigned."
)
config_key = "browser"
@classmethod
def config_cls(cls) -> type[BrowserToolConfig]:
return BrowserToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return bool(ctx.config.browser.enable)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(ctx.config.browser)
def __init__(
self,
config: BrowserToolConfig | None = None,
*,
backend_impl: Any = None,
) -> None:
self.config = config or BrowserToolConfig()
runtime = None
if backend_impl is None:
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
runtime = BrowserRuntime(headless=self.config.headless)
self._runtime = runtime
self._execution_lock = asyncio.Lock()
self._backends = SessionBackendPool(
self._make_backend,
backend_impl,
max_backends=self.config.max_sessions,
finalizer=runtime.close if runtime is not None else None,
)
@property
def read_only(self) -> bool:
return False
@property
def exclusive(self) -> bool:
return True
def _make_backend(self) -> Any:
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
return BrowserBackend(
width=self.config.width,
height=self.config.height,
start_url=self.config.start_url,
allowed_domains=self.config.allowed_domains,
runtime=self._runtime,
)
@staticmethod
def _req_ref(params: dict[str, Any], action: str) -> Any:
ref = params.get("ref")
if ref is None:
raise ValueError(f"action '{action}' requires an element 'ref' from the snapshot")
return ref
async def _dispatch(self, backend: Any, action: str, p: dict[str, Any]) -> tuple[str, str | None]:
"""Return (status, direct_text). If direct_text is set, it is returned as-is
(no snapshot appended)."""
if action == "navigate":
url = p.get("url")
if not url:
raise ValueError("action 'navigate' requires 'url'")
await backend.navigate(str(url))
return f"Navigated to {url}", None
if action == "snapshot":
return "Snapshot of the current page", None
if action == "click":
ref = self._req_ref(p, action)
await backend.click_ref(ref)
return f"Clicked element [{ref}]", None
if action == "type":
ref = self._req_ref(p, action)
text = p.get("text")
if text is None:
raise ValueError("action 'type' requires 'text'")
submit = bool(p.get("submit"))
await backend.fill_ref(ref, str(text), submit=submit)
return f"Typed into [{ref}]" + (" and pressed Enter" if submit else ""), None
if action == "select":
ref = self._req_ref(p, action)
value = p.get("value")
if value is None:
raise ValueError("action 'select' requires 'value'")
await backend.select_ref(ref, str(value))
return f"Selected '{value}' in [{ref}]", None
if action == "scroll":
direction = str(p.get("scroll_direction") or "down").lower()
if direction not in ("up", "down", "left", "right"):
raise ValueError("'scroll_direction' must be up/down/left/right")
await backend.scroll_page(direction, int(p.get("scroll_amount") or 3))
return f"Scrolled {direction}", None
if action == "key":
combo = p.get("text")
if not combo:
raise ValueError("action 'key' requires 'text' (e.g. 'Enter')")
await backend.key(str(combo))
return f"Pressed {combo}", None
if action == "back":
await backend.go_back()
return "Navigated back", None
if action == "read_text":
txt = await backend.read_text()
return "", f"Page text:\n{txt}"
raise ValueError(f"unknown action '{action}'")
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
async with self._execution_lock:
return await self._execute(action, **kwargs)
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
action = (action or "").strip()
if action not in _ACTIONS:
return ToolResult.error(
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
)
try:
backend = await self._backends.get()
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error: could not initialize browser backend: {type(exc).__name__}: {exc}"
)
try:
status, direct = await self._dispatch(backend, action, kwargs)
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
raise ValueError(f"navigation was blocked: {blocked}")
except ValueError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error executing browser '{action}': {type(exc).__name__}: {exc}"
)
if direct is not None:
return direct
try:
elements = await backend.dom_snapshot(self.config.max_elements)
snapshot = _format_elements(elements)
except Exception as exc:
snapshot = f"(could not read page elements: {type(exc).__name__}: {exc})"
try:
current = await backend.current_url()
except Exception:
current = ""
header = f"{status}\nCurrent page: {current}" if current else status
text_out = f"{header}\n\n{snapshot}"
if self.config.include_screenshot:
try:
png = await backend.screenshot()
return build_image_content_blocks(png, "image/png", "", text_out)
except Exception:
return text_out
return text_out
async def close(self) -> None:
await self._backends.close()
+333
View File
@@ -0,0 +1,333 @@
"""Screenshot-based computer control."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
import io
from typing import Any, Literal
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.computer_use_backends.base import SessionBackendPool
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
IntegerSchema,
NumberSchema,
StringSchema,
tool_parameters_schema,
)
from nanobot.config_base import Base
from nanobot.utils.helpers import build_image_content_blocks
_ACTIONS = [
"screenshot",
"left_click",
"right_click",
"middle_click",
"double_click",
"triple_click",
"mouse_move",
"left_click_drag",
"scroll",
"type",
"key",
"wait",
"navigate",
]
_CLICK_BUTTONS = {
"left_click": "left",
"double_click": "left",
"triple_click": "left",
"right_click": "right",
"middle_click": "middle",
}
_CLICK_COUNTS = {"double_click": 2, "triple_click": 3}
_MAX_WAIT_S = 10.0
class ComputerUseToolConfig(Base):
"""computer_use tool configuration."""
enable: bool = False
backend: Literal["desktop", "browser"] = "desktop"
target_width: int = Field(default=1280, ge=320, le=4096)
target_height: int = Field(default=800, ge=240, le=4096)
allowed_domains: list[str] = Field(default_factory=list)
start_url: str = "about:blank"
headless: bool = True
max_sessions: int = Field(default=8, ge=1, le=64)
def _fit_size(width: int, height: int, max_width: int, max_height: int) -> tuple[int, int]:
if width <= 0 or height <= 0:
return max(1, max_width), max(1, max_height)
scale = min(max_width / width, max_height / height, 1.0)
return max(1, round(width * scale)), max(1, round(height * scale))
def _scale_point(
x: int,
y: int,
source: tuple[int, int],
target: tuple[int, int],
) -> tuple[int, int]:
width, height = source
target_width, target_height = target
real_x = round(x * width / target_width) if target_width else x
real_y = round(y * height / target_height) if target_height else y
return (
max(0, min(real_x, max(0, width - 1))),
max(0, min(real_y, max(0, height - 1))),
)
@tool_parameters(
tool_parameters_schema(
action=StringSchema("The action to perform.", enum=_ACTIONS),
x=IntegerSchema(
description="X coordinate in the pixel space of the screenshot you were last shown.",
nullable=True,
),
y=IntegerSchema(
description="Y coordinate in the pixel space of the screenshot you were last shown.",
nullable=True,
),
text=StringSchema(
"Text to type (action=type; desktop supports ASCII), or a key/combo like "
"'ctrl+s' or 'Enter' (action=key).",
nullable=True,
),
scroll_direction=StringSchema(
"Scroll direction (action=scroll).", enum=["up", "down", "left", "right"], nullable=True
),
scroll_amount=IntegerSchema(
description="Number of scroll clicks (action=scroll).",
minimum=1,
maximum=100,
nullable=True,
),
duration=NumberSchema(
description="Seconds to wait (action=wait).",
minimum=0,
maximum=_MAX_WAIT_S,
nullable=True,
),
url=StringSchema("URL to open (action=navigate, browser backend only).", nullable=True),
required=["action"],
)
)
class ComputerUseTool(Tool):
"""Control a computer (desktop or browser) by looking at screenshots and acting."""
_scopes = {"core"} # never exposed to subagents — security-sensitive
name = "computer_use" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Control a computer via screenshots and mouse/keyboard. Each call performs ONE "
"action and returns a fresh screenshot of the resulting screen. Coordinates (x, y) "
"are in the pixel space of the screenshot you were last shown (top-left is 0,0). "
"The 'browser' backend additionally supports the 'navigate' action. Always start "
"with a 'screenshot' to see the screen, then act based on what you observe; after "
"each action re-check the new screenshot before the next step."
)
config_key = "computer_use"
@classmethod
def config_cls(cls) -> type[ComputerUseToolConfig]:
return ComputerUseToolConfig
@classmethod
def enabled(cls, ctx: ToolContext) -> bool:
return bool(ctx.config.computer_use.enable)
@classmethod
def create(cls, ctx: ToolContext) -> Tool:
return cls(ctx.config.computer_use)
def __init__(
self,
config: ComputerUseToolConfig | None = None,
*,
backend_impl: Any = None,
) -> None:
self.config = config or ComputerUseToolConfig()
runtime = None
if backend_impl is None and self.config.backend == "browser":
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserRuntime
runtime = BrowserRuntime(headless=self.config.headless)
self._runtime = runtime
self._execution_lock = asyncio.Lock()
self._backends = SessionBackendPool(
self._make_backend,
backend_impl,
max_backends=1 if self.config.backend == "desktop" else self.config.max_sessions,
finalizer=runtime.close if runtime is not None else None,
)
@property
def read_only(self) -> bool:
return False
@property
def exclusive(self) -> bool:
# Stateful single environment; must not run alongside other tools.
return True
def _make_backend(self) -> Any:
if self.config.backend == "browser":
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
return BrowserBackend(
width=self.config.target_width,
height=self.config.target_height,
start_url=self.config.start_url,
allowed_domains=self.config.allowed_domains,
runtime=self._runtime,
)
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
return DesktopBackend()
@staticmethod
def _downscale_png(png: bytes, target: tuple[int, int]) -> bytes:
try:
from PIL import Image # noqa: PLC0415
except Exception as exc:
raise ImportError(
"Pillow is required for computer_use. Install: pip install 'nanobot-ai[computer-use]'"
) from exc
tw, th = target
with Image.open(io.BytesIO(png)) as img:
if (img.width, img.height) == (tw, th):
return png
resized = img.convert("RGB").resize((tw, th)) # pyright: ignore[reportUnknownMemberType]
out = io.BytesIO()
resized.save(out, format="PNG")
return out.getvalue()
async def _dispatch(
self,
backend: Any,
action: str,
params: dict[str, Any],
source: tuple[int, int],
target: tuple[int, int],
) -> str:
def _xy() -> tuple[int, int]:
x, y = params.get("x"), params.get("y")
if x is None or y is None:
raise ValueError(f"action '{action}' requires integer 'x' and 'y'")
return _scale_point(int(x), int(y), source, target)
if action == "screenshot":
return "Took a screenshot"
if action == "wait":
duration = params.get("duration")
secs = 1.0 if duration is None else float(duration)
secs = max(0.0, min(secs, _MAX_WAIT_S))
await asyncio.sleep(secs)
return f"Waited {secs:g}s"
if action in _CLICK_BUTTONS:
rx, ry = _xy()
await backend.click(rx, ry, _CLICK_BUTTONS[action], _CLICK_COUNTS.get(action, 1))
return f"{action} at ({rx}, {ry})"
if action == "mouse_move":
rx, ry = _xy()
await backend.move(rx, ry)
return f"Moved to ({rx}, {ry})"
if action == "left_click_drag":
rx, ry = _xy()
await backend.drag(rx, ry)
return f"Dragged to ({rx}, {ry})"
if action == "scroll":
rx, ry = _xy()
direction = str(params.get("scroll_direction") or "down").lower()
if direction not in ("up", "down", "left", "right"):
raise ValueError("'scroll_direction' must be up/down/left/right")
amount = int(params.get("scroll_amount") or 3)
await backend.scroll(rx, ry, direction, amount)
return f"Scrolled {direction} by {amount} at ({rx}, {ry})"
if action == "type":
text = params.get("text")
if not text:
raise ValueError("action 'type' requires 'text'")
await backend.type_text(str(text))
return f"Typed {len(str(text))} characters"
if action == "key":
combo = params.get("text")
if not combo:
raise ValueError("action 'key' requires 'text' (e.g. 'ctrl+s')")
await backend.key(str(combo))
return f"Pressed {combo}"
if action == "navigate":
url = params.get("url")
if not url:
raise ValueError("action 'navigate' requires 'url'")
await backend.navigate(str(url))
return f"Navigated to {url}"
raise ValueError(f"unknown action '{action}'")
async def execute(self, action: str | None = None, **kwargs: Any) -> Any:
async with self._execution_lock:
return await self._execute(action, **kwargs)
async def _execute(self, action: str | None = None, **kwargs: Any) -> Any:
action = (action or "").strip()
if action not in _ACTIONS:
return ToolResult.error(
f"Error: unknown action '{action}'. Valid actions: {', '.join(_ACTIONS)}"
)
try:
backend = await self._backends.get()
real_w, real_h = await backend.dimensions()
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error: could not initialize computer_use backend: {type(exc).__name__}: {exc}"
)
source = (real_w, real_h)
target = _fit_size(real_w, real_h, self.config.target_width, self.config.target_height)
try:
status = await self._dispatch(backend, action, kwargs, source, target)
if blocked := getattr(backend, "pop_blocked_navigation", lambda: None)():
raise ValueError(f"navigation was blocked: {blocked}")
except ValueError as exc:
return ToolResult.error(f"Error: {exc}")
except NotImplementedError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return ToolResult.error(
f"Error executing computer_use '{action}': {type(exc).__name__}: {exc}"
)
# Return a fresh screenshot so the model sees the result of its action.
try:
png = await backend.screenshot()
png = self._downscale_png(png, target)
except ImportError as exc:
return ToolResult.error(f"Error: {exc}")
except Exception as exc:
return f"{status}\n(Could not capture screenshot: {type(exc).__name__}: {exc})"
label = f"{status} | screen {target[0]}x{target[1]} ({backend.environment})"
return build_image_content_blocks(png, "image/png", "", label)
async def close(self) -> None:
await self._backends.close()
@@ -0,0 +1 @@
"""Computer-use backend adapters."""
@@ -0,0 +1,139 @@
"""Backend interface for the ``computer_use`` tool.
A backend is the *actuator* + *screenshot source* for one execution environment
(the local desktop, a headless browser, a VM, ...). The tool layer owns the
agent loop, coordinate scaling, screenshot downscaling and safety gating; a
backend only has to perform primitive actions and grab a screenshot.
Coordinate contract: every ``x``/``y`` passed to a backend is already in **real
device pixels** (the same pixel space as :meth:`screenshot`). The tool scales the
model's target-space coordinates to real pixels before calling the backend, so
backends never deal with the downscaled space.
"""
from __future__ import annotations
import asyncio
from abc import ABC, abstractmethod
from collections import OrderedDict
from collections.abc import Awaitable, Callable
from typing import Any
from nanobot.agent.tools.context import current_request_session_key
class ComputerBackend(ABC):
"""Primitive GUI actions + screenshot for one execution environment."""
#: "desktop" or "browser" — surfaced to the model so it knows the context.
environment: str = "desktop"
@abstractmethod
async def dimensions(self) -> tuple[int, int]:
"""Return the real screenshot pixel size as ``(width, height)``."""
@abstractmethod
async def screenshot(self) -> bytes:
"""Return a PNG screenshot of the current screen at real pixel size."""
@abstractmethod
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
"""Click at ``(x, y)``. ``button`` in {left,right,middle}; ``count`` for double/triple."""
@abstractmethod
async def move(self, x: int, y: int) -> None:
"""Move the cursor to ``(x, y)`` without clicking."""
@abstractmethod
async def drag(self, x: int, y: int) -> None:
"""Press at the current cursor position and drag to ``(x, y)``, then release."""
@abstractmethod
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
"""Scroll at ``(x, y)``. ``direction`` in {up,down,left,right}; ``amount`` in clicks."""
@abstractmethod
async def type_text(self, text: str) -> None:
"""Type ``text`` at the current focus."""
@abstractmethod
async def key(self, combo: str) -> None:
"""Press a key or combo, e.g. ``"ctrl+s"`` / ``"Enter"`` (backend-specific syntax)."""
async def navigate(self, url: str) -> None:
"""Navigate to ``url`` (browser backends only)."""
raise NotImplementedError(
f"'navigate' is not supported by the {self.environment} backend"
)
async def close(self) -> None:
"""Release any resources (browser process, etc.). Safe to call repeatedly."""
return None
class SessionBackendPool:
"""Keep stateful backends isolated by nanobot session."""
def __init__(
self,
factory: Callable[[], Any],
injected: Any = None,
*,
max_backends: int = 8,
finalizer: Callable[[], Awaitable[None]] | None = None,
) -> None:
if max_backends < 1:
raise ValueError("max_backends must be at least 1")
self._factory = factory
self._injected = injected
self._max_backends = max_backends
self._finalizer = finalizer
self._backends: OrderedDict[str, Any] = OrderedDict()
self._lock = asyncio.Lock()
self._closed = False
async def get(self) -> Any:
async with self._lock:
if self._closed:
raise RuntimeError("computer-use backend pool is closed")
if self._injected is not None:
return self._injected
key = current_request_session_key() or "default"
backend = self._backends.get(key)
if backend is not None:
self._backends.move_to_end(key)
return backend
if len(self._backends) >= self._max_backends:
_, stale = self._backends.popitem(last=False)
await stale.close()
backend = self._factory()
self._backends[key] = backend
return backend
async def close(self) -> None:
async with self._lock:
if self._closed:
return
self._closed = True
backends = (
[self._injected]
if self._injected is not None
else list(self._backends.values())
)
self._injected = None
self._backends.clear()
finalizer, self._finalizer = self._finalizer, None
results = await asyncio.gather(
*(backend.close() for backend in backends if backend is not None),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, BaseException)]
if finalizer is not None:
try:
await finalizer()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close computer-use backends", errors)
@@ -0,0 +1,382 @@
"""Playwright backend shared by browser and computer_use."""
from __future__ import annotations
import asyncio
import importlib
from collections.abc import Sequence
from typing import Any, cast
from urllib.parse import urlparse, urlunparse
from loguru import logger
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
from nanobot.security.network import validate_url_target
_MISSING = (
"Browser computer-use backend needs 'playwright'. Install with: "
"pip install 'nanobot-ai[computer-use]' && playwright install chromium"
)
_SCROLL_PIXELS = 100 # one "scroll click" ~= this many pixels
# Tags visible interactive elements with data-nanobot-ref and returns a compact
# list. Refs are reassigned per call. Used by DOM/accessibility mode.
_SNAPSHOT_JS = r"""
(max) => {
const SEL = 'a,button,input,textarea,select,[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=switch],[onclick],[contenteditable=""],[contenteditable=true]';
const out = [];
let ref = 0;
for (const el of document.querySelectorAll(SEL)) {
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
if (r.width <= 0 || r.height <= 0) continue;
if (s.visibility === 'hidden' || s.display === 'none' || s.opacity === '0') continue;
ref++;
el.setAttribute('data-nanobot-ref', String(ref));
let name = (el.getAttribute('aria-label') || el.innerText || el.value ||
el.getAttribute('placeholder') || el.getAttribute('name') ||
el.getAttribute('title') || '');
name = name.replace(/\s+/g, ' ').trim().slice(0, 120);
out.push({
ref: ref,
tag: el.tagName.toLowerCase(),
role: el.getAttribute('role') || '',
type: el.getAttribute('type') || '',
name: name,
href: el.getAttribute('href') || ''
});
if (out.length >= max) break;
}
return out;
}
"""
# CUA/xdotool-ish modifier names -> Playwright modifiers.
_MODIFIERS = {
"ctrl": "Control", "control": "Control",
"alt": "Alt", "option": "Alt",
"shift": "Shift",
"cmd": "Meta", "meta": "Meta", "super": "Meta", "win": "Meta",
}
# Common single-key names -> Playwright key names.
_KEYS = {
"return": "Enter", "enter": "Enter", "tab": "Tab", "esc": "Escape",
"escape": "Escape", "backspace": "Backspace", "delete": "Delete",
"space": "Space", "up": "ArrowUp", "down": "ArrowDown",
"left": "ArrowLeft", "right": "ArrowRight",
"page_down": "PageDown", "pagedown": "PageDown",
"page_up": "PageUp", "pageup": "PageUp", "home": "Home", "end": "End",
}
def _validate_browser_url(
url: str,
allowed_domains: Sequence[str] = (),
*,
navigation: bool = True,
) -> tuple[bool, str]:
if url == "about:blank":
return True, ""
parsed = urlparse(url)
if not navigation and parsed.scheme in {"blob", "data"}:
return True, ""
target = url
if parsed.scheme in {"ws", "wss"}:
target = urlunparse(parsed._replace(scheme="https" if parsed.scheme == "wss" else "http"))
if navigation and allowed_domains:
host = (parsed.hostname or "").rstrip(".").lower()
allowed = any(
normalized and (host == normalized or host.endswith(f".{normalized}"))
for domain in allowed_domains
if (normalized := domain.strip().lstrip(".").rstrip(".").lower())
)
if not allowed:
return False, f"host {host or '<missing>'} is not in allowed_domains"
return validate_url_target(target)
def _playwright_key(combo: str) -> str:
parts = [p.strip() for p in combo.split("+") if p.strip()]
out: list[str] = []
for part in parts:
low = part.lower()
if low in _MODIFIERS:
out.append(_MODIFIERS[low])
elif low in _KEYS:
out.append(_KEYS[low])
elif len(part) == 1:
out.append(part)
else:
out.append(part.capitalize())
return "+".join(out)
class BrowserRuntime:
"""One lazily started browser process shared by isolated session contexts."""
def __init__(self, *, headless: bool = True) -> None:
self._headless = headless
self._lock = asyncio.Lock()
self._playwright: Any = None
self._browser: Any = None
async def get(self) -> Any:
if self._browser is not None:
return self._browser
async with self._lock:
if self._browser is not None:
return self._browser
try:
playwright = importlib.import_module("playwright.async_api")
async_playwright = cast(Any, playwright).async_playwright
except ImportError as exc:
raise ImportError(_MISSING) from exc
self._playwright = await async_playwright().start()
try:
self._browser = await self._playwright.chromium.launch(
headless=self._headless
)
except BaseException:
await self.close()
raise
return self._browser
async def close(self) -> None:
browser, playwright = self._browser, self._playwright
self._browser = self._playwright = None
errors: list[BaseException] = []
closers = (
browser.close if browser is not None else None,
playwright.stop if playwright is not None else None,
)
for close in closers:
if close is None:
continue
try:
await close()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close browser runtime", errors)
class BrowserBackend(ComputerBackend):
environment = "browser"
def __init__(
self,
*,
width: int = 1280,
height: int = 800,
headless: bool = True,
start_url: str = "about:blank",
allowed_domains: Sequence[str] = (),
runtime: BrowserRuntime | None = None,
) -> None:
self._width = width
self._height = height
self._start_url = start_url
self._allowed_domains = tuple(allowed_domains)
self._runtime = runtime or BrowserRuntime(headless=headless)
self._owns_runtime = runtime is None
self._context: Any = None
self._page: Any = None
self._last_pos = (0, 0)
self._blocked_navigation: str | None = None
async def _require_url(self, url: str, label: str) -> None:
ok, error = await asyncio.to_thread(
_validate_browser_url,
url,
self._allowed_domains,
)
if not ok:
raise ValueError(f"{label} is blocked: {error}")
async def _route_request(self, route: Any) -> None:
request = route.request
navigation = bool(request.is_navigation_request())
ok, error = await asyncio.to_thread(
_validate_browser_url,
request.url,
self._allowed_domains,
navigation=navigation,
)
if ok:
await route.continue_()
return
if navigation:
self._blocked_navigation = error
logger.warning("Blocked browser request to {}: {}", request.url, error)
await route.abort("blockedbyclient")
async def _route_web_socket(self, web_socket: Any) -> None:
ok, error = await asyncio.to_thread(
_validate_browser_url,
web_socket.url,
self._allowed_domains,
navigation=False,
)
if not ok:
logger.warning("Blocked browser WebSocket to {}: {}", web_socket.url, error)
await web_socket.close(code=1008, reason="Blocked by nanobot network policy")
return
await web_socket.connect_to_server()
def pop_blocked_navigation(self) -> str | None:
error = self._blocked_navigation
self._blocked_navigation = None
return error
async def _ensure(self) -> Any:
if self._page is not None:
return self._page
await self._require_url(self._start_url, "start_url")
try:
browser = await self._runtime.get()
self._context = await browser.new_context(
viewport={"width": self._width, "height": self._height},
device_scale_factor=1,
service_workers="block",
)
await self._context.route("**/*", self._route_request)
await self._context.route_web_socket("**/*", self._route_web_socket)
self._page = await self._context.new_page()
if self._start_url != "about:blank":
await self._page.goto(self._start_url)
return self._page
except BaseException:
await self.close()
raise
async def dimensions(self) -> tuple[int, int]:
await self._ensure()
vp = self._page.viewport_size or {"width": self._width, "height": self._height}
return vp["width"], vp["height"]
async def screenshot(self) -> bytes:
page = await self._ensure()
return await page.screenshot()
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
page = await self._ensure()
await page.mouse.click(x, y, button=button, click_count=count)
self._last_pos = (x, y)
async def move(self, x: int, y: int) -> None:
page = await self._ensure()
await page.mouse.move(x, y)
self._last_pos = (x, y)
async def drag(self, x: int, y: int) -> None:
page = await self._ensure()
sx, sy = self._last_pos
await page.mouse.move(sx, sy)
await page.mouse.down()
await page.mouse.move(x, y)
await page.mouse.up()
self._last_pos = (x, y)
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
page = await self._ensure()
await page.mouse.move(x, y)
pixels = max(1, amount) * _SCROLL_PIXELS
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
await page.mouse.wheel(dx, dy)
async def type_text(self, text: str) -> None:
page = await self._ensure()
await page.keyboard.type(text)
async def key(self, combo: str) -> None:
page = await self._ensure()
key = _playwright_key(combo)
if key:
await page.keyboard.press(key)
async def navigate(self, url: str) -> None:
await self._require_url(url, "navigation")
page = await self._ensure()
await page.goto(url)
self._last_pos = (0, 0)
# --- DOM / accessibility mode (act by element ref, not pixels) ---
async def dom_snapshot(self, max_elements: int = 200) -> list[dict[str, Any]]:
"""Tag visible interactive elements with ``data-nanobot-ref`` and return them.
Each entry: ``{ref, tag, role, type, name, href}``. Refs are reassigned on
every snapshot, so callers should act on the latest snapshot.
"""
page = await self._ensure()
return cast(list[dict[str, Any]], await page.evaluate(_SNAPSHOT_JS, max_elements))
def _ref_selector(self, ref: int) -> str:
return f'[data-nanobot-ref="{int(ref)}"]'
async def click_ref(self, ref: int) -> None:
page = await self._ensure()
await page.click(self._ref_selector(ref), timeout=5000)
async def fill_ref(self, ref: int, text: str, submit: bool = False) -> None:
page = await self._ensure()
sel = self._ref_selector(ref)
await page.fill(sel, text, timeout=5000)
if submit:
await page.press(sel, "Enter")
async def select_ref(self, ref: int, value: str) -> None:
page = await self._ensure()
sel = self._ref_selector(ref)
try:
await page.select_option(sel, value, timeout=3000)
except Exception:
# Models usually pass the visible label, not the option value.
await page.select_option(sel, label=value, timeout=3000)
async def scroll_page(self, direction: str, amount: int) -> None:
page = await self._ensure()
pixels = max(1, amount) * _SCROLL_PIXELS
dx = pixels if direction == "right" else -pixels if direction == "left" else 0
dy = pixels if direction == "down" else -pixels if direction == "up" else 0
await page.evaluate("([x, y]) => window.scrollBy(x, y)", [dx, dy])
async def go_back(self) -> None:
page = await self._ensure()
await page.go_back()
async def read_text(self, max_chars: int = 4000) -> str:
page = await self._ensure()
txt = await page.evaluate("() => document.body ? document.body.innerText : ''")
return (txt or "")[:max_chars]
async def current_url(self) -> str:
page = await self._ensure()
return page.url
async def close(self) -> None:
context = self._context
self._context = self._page = None
error: BaseException | None = None
if context is not None:
try:
await context.close()
except BaseException as exc:
error = exc
if self._owns_runtime:
try:
await self._runtime.close()
except BaseException as exc:
if error is not None:
raise BaseExceptionGroup("failed to close browser backend", [error, exc])
raise
if error is not None:
raise error
@@ -0,0 +1,134 @@
"""PyAutoGUI desktop backend with HiDPI coordinate correction."""
from __future__ import annotations
import asyncio
import io
from typing import Any
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend
_MISSING = (
"Desktop computer-use backend needs 'pyautogui' and 'pillow'. "
"Install with: pip install 'nanobot-ai[computer-use]'"
)
# xdotool/CUA-style key names -> PyAutoGUI key names.
_KEY_ALIASES = {
"return": "enter",
"ctrl": "ctrl",
"control": "ctrl",
"cmd": "command",
"super": "win",
"win": "win",
"page_down": "pagedown",
"page_up": "pageup",
"pagedown": "pagedown",
"pageup": "pageup",
"esc": "esc",
"escape": "esc",
}
class DesktopBackend(ComputerBackend):
environment = "desktop"
def __init__(self) -> None:
self._pg: Any = None
self._ratio_x = 1.0
self._ratio_y = 1.0
self._dims: tuple[int, int] | None = None
def _ensure(self) -> Any:
if self._pg is not None:
return self._pg
try:
import pyautogui # noqa: PLC0415
except Exception as exc: # ImportError, or platform display errors
raise ImportError(_MISSING) from exc
self._pg = pyautogui
return pyautogui
def _grab_png_and_size(self) -> tuple[bytes, int, int]:
pg = self._ensure()
img = pg.screenshot()
buf = io.BytesIO()
img.save(buf, format="PNG")
width, height = img.size
# Refresh logical<->physical ratio from the actual grab.
try:
logical_w, logical_h = pg.size()
self._ratio_x = (logical_w / width) if width else 1.0
self._ratio_y = (logical_h / height) if height else 1.0
except Exception:
self._ratio_x = self._ratio_y = 1.0
self._dims = (width, height)
return buf.getvalue(), width, height
def _to_logical(self, x: int, y: int) -> tuple[int, int]:
return round(x * self._ratio_x), round(y * self._ratio_y)
async def dimensions(self) -> tuple[int, int]:
if self._dims is not None:
return self._dims
_, w, h = await asyncio.to_thread(self._grab_png_and_size)
return w, h
async def screenshot(self) -> bytes:
png, _, _ = await asyncio.to_thread(self._grab_png_and_size)
return png
async def click(self, x: int, y: int, button: str = "left", count: int = 1) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(pg.click, lx, ly, clicks=count, button=button)
async def move(self, x: int, y: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(pg.moveTo, lx, ly)
async def drag(self, x: int, y: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
await asyncio.to_thread(
pg.dragTo,
lx,
ly,
duration=0.3,
tween=pg.easeInOutQuad,
button="left",
)
async def scroll(self, x: int, y: int, direction: str, amount: int) -> None:
pg = self._ensure()
lx, ly = self._to_logical(x, y)
clicks = max(1, amount)
await asyncio.to_thread(pg.moveTo, lx, ly)
if direction in ("up", "down"):
await asyncio.to_thread(pg.scroll, clicks if direction == "up" else -clicks)
else:
await asyncio.to_thread(pg.hscroll, clicks if direction == "right" else -clicks)
async def type_text(self, text: str) -> None:
if not text.isascii():
raise ValueError(
"desktop text input supports ASCII key events only; "
"use the browser backend for Unicode text"
)
pg = self._ensure()
await asyncio.to_thread(pg.typewrite, text, 0.01)
async def key(self, combo: str) -> None:
pg = self._ensure()
keys = [
_KEY_ALIASES.get(part.strip().lower(), part.strip().lower())
for part in combo.split("+")
if part.strip()
]
if not keys:
return
if len(keys) == 1:
await asyncio.to_thread(pg.press, keys[0])
else:
await asyncio.to_thread(pg.hotkey, *keys)
-16
View File
@@ -785,22 +785,6 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li
return best_ratio, best_start, best_window_lines, hints
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
"""Locate old_text in content with a multi-level fallback chain:
1. Exact substring match
2. Line-trimmed sliding window (handles indentation differences)
3. Smart quote normalization (curly straight quotes)
Both inputs should use LF line endings (caller normalises CRLF).
Returns (matched_fragment, count) or (None, 0).
"""
matches = _find_matches(content, old_text)
if not matches:
return None, 0
return matches[0].text, len(matches)
@tool_parameters(
tool_parameters_schema(
path=StringSchema("The file path to edit"),
+3
View File
@@ -187,5 +187,8 @@ class _LegacyErrorPrefixTool(Tool):
return ToolResult.error(result)
return result
async def close(self) -> None:
await self._wrapped.close()
def __getattr__(self, name: str) -> Any:
return getattr(self._wrapped, name)
+1 -9
View File
@@ -3,15 +3,7 @@
from pathlib import Path
from nanobot.config.paths import get_media_dir
from nanobot.security.workspace_policy import (
is_path_within,
resolve_allowed_path,
)
def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory."""
return is_path_within(path, directory)
from nanobot.security.workspace_policy import resolve_allowed_path
def resolve_workspace_path(
+13
View File
@@ -200,6 +200,19 @@ class ToolRegistry:
except Exception as e:
return ToolResult.error(f"Error executing {name}: {str(e)}" + hint)
async def close(self) -> None:
"""Close every registered tool, attempting all cleanups."""
errors: list[BaseException] = []
for tool in self._tools.values():
try:
await tool.close()
except BaseException as exc:
errors.append(exc)
if len(errors) == 1:
raise errors[0]
if errors:
raise BaseExceptionGroup("failed to close tools", errors)
@property
def tool_names(self) -> list[str]:
"""Get list of registered tool names."""
+12 -75
View File
@@ -18,7 +18,6 @@ from typing import Any, cast
from urllib.parse import urlparse
import httpx
import yaml
from loguru import logger
from nanobot.apps.protocol import app_manifest, compact_dict
@@ -28,7 +27,6 @@ from nanobot.security.workspace_policy import is_path_within
CLI_ANYTHING_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/registry.json"
CLI_ANYTHING_PUBLIC_REGISTRY_URL = "https://hkuds.github.io/CLI-Anything/public_registry.json"
CLI_ANYTHING_RAW_BASE = "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main"
AGENT_PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"
NANOBOT_EXTENSION_REGISTRY_URL = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main/registry.json"
NANOBOT_EXTENSION_RAW_BASE = "https://raw.githubusercontent.com/Re-bin/nanobot-extension/main"
_CATALOG_SOURCES = (
@@ -43,8 +41,6 @@ _MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_SAFE_NPM_DIR_RE = re.compile(r"^[a-z0-9._-]+$", re.IGNORECASE)
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SKILL_FRONTMATTER_RE = re.compile(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?", re.DOTALL)
_SKILL_NAME_LINE_RE = re.compile(r"^name\s*:.*$", re.MULTILINE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ENDORSEMENT_WORD_RE = re.compile(r"\bofficial\s+", re.IGNORECASE)
_ARTIFACT_EXTENSIONS = frozenset({
@@ -215,21 +211,10 @@ def _as_object_dict(value: object) -> dict[str, Any] | None:
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).replace("_", "-").strip("-")
return f"cli-app-{clean or 'app'}"
def _legacy_skill_name(name: str) -> str:
"""Return the workspace skill name emitted before Agent Plugins support."""
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _plugin_skill_relative_path(name: str) -> str:
skill_name = _safe_skill_name(name)
return f"plugins/{skill_name}/skills/{skill_name}/SKILL.md"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
@@ -628,7 +613,7 @@ class CliAppManager:
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": self.skill_relative_path(installed_name),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
@@ -655,20 +640,7 @@ class CliAppManager:
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
skill_name = _safe_skill_name(name)
return self.workspace / "plugins" / skill_name / "skills" / skill_name / "SKILL.md"
def _legacy_skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _legacy_skill_name(name) / "SKILL.md"
def _installed_skill_path(self, name: str) -> Path:
path = self._skill_path(name)
legacy_path = self._legacy_skill_path(name)
return legacy_path if not path.is_file() and legacy_path.is_file() else path
def skill_relative_path(self, name: str) -> str:
"""Return the existing skill path, falling back to the canonical plugin path."""
return self._installed_skill_path(name).relative_to(self.workspace).as_posix()
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
@@ -705,7 +677,7 @@ class CliAppManager:
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._installed_skill_path(name).is_file(),
"skill_installed": self._skill_path(name).is_file(),
"manifest": self._manifest_payload(app, logo_url=logo_url, brand_color=brand_color),
}
@@ -741,8 +713,7 @@ class CliAppManager:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
strategy = self._strategy(app)
skill_path = _plugin_skill_relative_path(name)
plugin_path = f"plugins/{_safe_skill_name(name)}"
skill_path = f"skills/{_safe_skill_name(name)}/SKILL.md"
capabilities = [
compact_dict({
"type": "cli",
@@ -755,13 +726,13 @@ class CliAppManager:
install = compact_dict({
"supported": install_supported,
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": ["entry_point_available"] if entry_point else [],
})
remove = compact_dict({
"supported": strategy != "unsupported",
"strategy": strategy,
"managed_paths": [plugin_path],
"managed_paths": [skill_path],
"verification": (
["package_manager_ok", "entry_point_absent", "managed_paths_absent"]
if strategy not in {"bundled", "unsupported"}
@@ -1061,10 +1032,11 @@ class CliAppManager:
name = str(app.get("name") or "unknown")
display = str(app.get("display_name") or name)
entry = str(app.get("entry_point") or f"cli-anything-{name}")
description = (_catalog_description(app) or f"Use {display} from nanobot.")[:1024]
description = _catalog_description(app) or f"Use {display} from nanobot."
return f"""---
name: {_safe_skill_name(name)}
description: {json.dumps(description, ensure_ascii=False)}
description: >-
{description}
---
# {display}
@@ -1100,53 +1072,18 @@ Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not in
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
def _normalise_skill(self, content: str, app: dict[str, Any]) -> str:
"""Give a catalog skill the identity required by its plugin directory."""
match = _SKILL_FRONTMATTER_RE.match(content)
if match is None:
return self._fallback_skill(app)
try:
metadata = _as_object_dict(cast(object, yaml.safe_load(match.group(1))))
except yaml.YAMLError:
return self._fallback_skill(app)
description = metadata.get("description") if metadata is not None else None
if not isinstance(description, str) or not 1 <= len(description.strip()) <= 1024:
return self._fallback_skill(app)
name = _safe_skill_name(str(app["name"]))
frontmatter, replaced = _SKILL_NAME_LINE_RE.subn(f"name: {name}", match.group(1), count=1)
if not replaced:
frontmatter = f"name: {name}\n{frontmatter}"
body = content[match.end():].lstrip()
return f"---\n{frontmatter.strip()}\n---\n\n{body}"
def install_skill(self, app: dict[str, Any]) -> Path:
path = self._skill_path(str(app["name"]))
path.parent.mkdir(parents=True, exist_ok=True)
content = self._fetch_skill_content(app) or self._fallback_skill(app)
content = self._normalise_skill(content, app)
content = self._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
plugin_root = path.parents[2]
manifest = compact_dict({
"$schema": AGENT_PLUGIN_SCHEMA,
"name": _safe_skill_name(str(app["name"])),
"version": str(app.get("version") or ""),
"description": _catalog_description(app),
})
_write_json(plugin_root / "plugin.json", manifest)
legacy_dir = self._legacy_skill_path(str(app["name"])).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
return path
def remove_skill(self, name: str) -> None:
plugin_root = self._skill_path(name).parents[2]
if plugin_root.is_dir():
shutil.rmtree(plugin_root)
legacy_dir = self._legacy_skill_path(name).parent
if legacy_dir.is_dir():
shutil.rmtree(legacy_dir)
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> dict[str, Any]:
installed = self._load_installed()
+1 -13
View File
@@ -12,15 +12,6 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible CLI app annotations for the current turn."""
if skip:
return []
text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
return runtime_lines_for_request(text, metadata, workspace)
def runtime_lines_for_request(
text: str,
metadata: Mapping[str, Any] | None,
@@ -29,9 +20,6 @@ def runtime_lines_for_request(
"""Return CLI App annotations from an immutable request snapshot."""
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
from nanobot.apps.cli import CliAppManager
manager = CliAppManager(workspace=workspace)
structured_items = cast(list[Any], structured)
mentions = [
cast(Mapping[str, Any], item) for item in structured_items
@@ -44,7 +32,7 @@ def runtime_lines_for_request(
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
f"skill={manager.skill_relative_path(str(item['name']))}). "
f"skill=skills/cli-app-{str(item['name']).strip().lower()}/SKILL.md). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
if str(item.get("name") or "").strip()
+2
View File
@@ -18,6 +18,7 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
@dataclass
@@ -32,6 +33,7 @@ class InboundMessage:
media: list[str] = field(default_factory=list) # Media URLs
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions
require_existing_session: bool = False
@property
def session_key(self) -> str:
+2
View File
@@ -262,6 +262,7 @@ class BaseChannel(ABC):
session_key: str | None = None,
is_dm: bool = False,
authorization_id: str | None = None,
require_existing_session: bool = False,
) -> None:
"""Handle a message after checking its authorization subject.
@@ -314,6 +315,7 @@ class BaseChannel(ABC):
media=media or [],
metadata=meta,
session_key_override=session_key,
require_existing_session=require_existing_session,
)
await self.bus.publish_inbound(msg)
-9
View File
@@ -470,15 +470,6 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
return "", []
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
# =============================================================================
# QR scan-to-create onboarding
#
-8
View File
@@ -658,11 +658,6 @@ class MattermostChannel(BaseChannel):
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._require_http_client().put(path, json=json_data)
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def _create_post(
self,
channel_id: str,
@@ -681,9 +676,6 @@ class MattermostChannel(BaseChannel):
body["file_ids"] = file_ids
return await self._api_post("/api/v4/posts", body)
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
path = Path(file_path)
if not path.exists():
-5
View File
@@ -811,11 +811,6 @@ class MSTeamsChannel(BaseChannel):
except Exception as e:
self.logger.warning("Failed to save conversation refs: {}", e)
def _save_refs(self, *, prune: bool = True) -> None:
"""Persist conversation references."""
with self._refs_guard:
self._save_refs_locked(prune=prune)
async def _get_access_token(self) -> str:
"""Fetch an access token for Bot Framework / Azure Bot auth."""
@@ -228,7 +228,8 @@ def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monke
),
}
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
@@ -378,7 +379,8 @@ def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_
raise OSError("replace failed")
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
assert set(persisted.keys()) == {"conv-old"}
@@ -934,7 +936,8 @@ def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
),
}
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
assert set(ch._conversation_refs) == {"teams-good"}
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
+2
View File
@@ -431,6 +431,7 @@ class SignalChannel(BaseChannel):
session_key: str | None = None,
is_dm: bool = False,
authorization_id: str | None = None,
require_existing_session: bool = False,
) -> None:
"""Handle an inbound message whose policy has already been checked.
@@ -453,6 +454,7 @@ class SignalChannel(BaseChannel):
media=media or [],
metadata=meta,
session_key_override=session_key,
require_existing_session=require_existing_session,
)
)
+125 -30
View File
@@ -20,7 +20,10 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.events import (
OUTBOUND_META_AGENT_UI,
OutboundMessage,
)
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
@@ -30,7 +33,6 @@ from nanobot.bus.outbound_events import (
TurnEndEvent,
TurnModelUpdatedEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -49,6 +51,7 @@ from nanobot.security.workspace_access import (
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.webui_turns import (
clear_websocket_turn_if_current,
clear_websocket_turns,
mark_websocket_turn_transcript_persistence_failed,
register_queued_websocket_turn_if_idle,
websocket_turn_id,
@@ -82,6 +85,7 @@ from nanobot.webui.session_access import (
session_mentions_runtime_context,
)
from nanobot.webui.sidebar_state import write_webui_sidebar_state
from nanobot.webui.temporary_chats import TemporaryChatError
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
from nanobot.webui.transcription_ws import webui_transcription_event
from nanobot.webui.websocket_logging import websockets_server_logger
@@ -280,21 +284,6 @@ class WebSocketConfig(Base):
)
def publish_runtime_model_update(
bus: MessageBus,
model: str,
model_preset: str | None,
) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(
outbound_message_for_event(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
)
)
def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip()
@@ -394,6 +383,7 @@ class WebSocketChannel(BaseChannel):
self._ingress = gateway.ingress
self._transcripts = gateway.transcripts
self._workspaces = gateway.workspaces
self._temporary_chats = gateway.temporary_chats
self._session_access = (
WebuiSessionAccess(gateway.session_manager)
if gateway.session_manager is not None
@@ -412,6 +402,33 @@ class WebSocketChannel(BaseChannel):
self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id)
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
chats = self._conn_chats.get(connection)
if chats is not None:
chats.discard(chat_id)
if not chats:
self._conn_chats.pop(connection, None)
subscribers = self._subs.get(chat_id)
if subscribers is not None:
subscribers.discard(connection)
if not subscribers:
self._subs.pop(chat_id, None)
def _clear_stream_buffers(self, chat_id: str) -> None:
for key in tuple(self._stream_text_buffers):
if key[0] == chat_id:
self._stream_text_buffers.pop(key, None)
async def _discard_connection_owned_chat(
self,
connection: ServerConnection,
chat_id: str,
) -> None:
await self._temporary_chats.discard(connection, chat_id)
self._detach(connection, chat_id)
clear_websocket_turns(chat_id)
self._clear_stream_buffers(chat_id)
async def send_webui_protocol_error(
self,
connection: ServerConnection,
@@ -440,16 +457,16 @@ class WebSocketChannel(BaseChannel):
)
await self._hydrate_after_subscribe(fork_id)
def _cleanup_connection(self, connection: ServerConnection) -> None:
async def _cleanup_connection(self, connection: ServerConnection) -> None:
"""Remove *connection* from every subscription set; safe to call multiple times."""
chat_ids = self._conn_chats.pop(connection, set())
chat_ids = tuple(self._conn_chats.get(connection, ()))
for cid in chat_ids:
subs = self._subs.get(cid)
if subs is None:
continue
subs.discard(connection)
if not subs:
self._subs.pop(cid, None)
if self._temporary_chats.owns(connection, cid):
await self._discard_connection_owned_chat(connection, cid)
else:
self._detach(connection, cid)
for cid in self._temporary_chats.chat_ids_for_owner(connection):
await self._discard_connection_owned_chat(connection, cid)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
@@ -502,7 +519,7 @@ class WebSocketChannel(BaseChannel):
try:
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
except Exception as e:
self.logger.warning("failed to send {} event: {}", event, e)
@@ -729,7 +746,7 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.debug("connection ended: {}", e)
finally:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
# -- Inbound WebSocket envelopes ---------------------------------------
@@ -764,14 +781,46 @@ class WebSocketChannel(BaseChannel):
)
await self._hydrate_after_subscribe(new_id)
return
if t == "new_temporary_chat":
try:
new_id = self._temporary_chats.create(
connection,
trusted_webui=connection in self._webui_connections,
)
except TemporaryChatError as exc:
await self._send_event(connection, "error", detail=exc.detail)
return
self._attach(connection, new_id)
await self._send_event(
connection,
"attached",
chat_id=new_id,
temporary=True,
)
return
if t == "fork_chat":
await handle_webui_fork_chat(self, connection, envelope)
return
if t == "discard_temporary_chat":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid temporary chat_id")
return
try:
await self._discard_connection_owned_chat(connection, cid)
except TemporaryChatError as exc:
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
return
if t == "attach":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
try:
self._temporary_chats.validate_attach(cid)
except TemporaryChatError as exc:
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
return
self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
@@ -805,6 +854,11 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
try:
self._temporary_chats.validate_workspace_update(cid)
except TemporaryChatError as exc:
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._workspaces.scope_for_set_request(
@@ -873,6 +927,21 @@ class WebSocketChannel(BaseChannel):
)
return
try:
temporary_policy = self._temporary_chats.message_policy(
connection,
cid,
content,
)
except TemporaryChatError as exc:
await self._send_event(
connection,
"error",
detail=exc.detail,
**rejection_fields,
)
return
raw_media = envelope.get("media")
media_paths: list[str] = []
if raw_media is not None:
@@ -895,6 +964,8 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
if temporary_policy is not None:
self._temporary_chats.register_media(connection, cid, media_paths)
# Allow media-only turns (content may be empty when attachments are present).
if not content.strip() and not media_paths:
@@ -907,16 +978,21 @@ class WebSocketChannel(BaseChannel):
return
# Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid)
if temporary_policy is None or temporary_policy.hydrate_transcript:
await self._hydrate_after_subscribe(cid)
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
scope = await self._workspace_scope_or_error(
connection,
lambda: self._workspaces.scope_for_message(
lambda: (
temporary_policy.workspace_scope
if temporary_policy is not None
else self._workspaces.scope_for_message(
envelope,
chat_id=cid,
chat_running=websocket_turn_wall_started_at(cid) is not None,
controls_available=self._workspace_controls_available(connection),
)
),
chat_id=cid,
turn_id=turn_id,
@@ -969,7 +1045,13 @@ class WebSocketChannel(BaseChannel):
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
accepted = False
try:
if is_webui:
if (
is_webui
and (
temporary_policy is None
or temporary_policy.persist_transcript
)
):
self._transcripts.append_user_message(
cid,
content,
@@ -998,6 +1080,16 @@ class WebSocketChannel(BaseChannel):
media=media_paths or None,
metadata=metadata,
is_dm=False,
session_key=(
temporary_policy.session_key
if temporary_policy is not None
else None
),
require_existing_session=(
temporary_policy.require_existing_session
if temporary_policy is not None
else False
),
)
accepted = True
finally:
@@ -1058,6 +1150,7 @@ class WebSocketChannel(BaseChannel):
self._conn_default.clear()
self._webui_connections.clear()
self._tokens.clear()
self._temporary_chats.close()
async def _safe_send_to(
self,
@@ -1070,7 +1163,7 @@ class WebSocketChannel(BaseChannel):
try:
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
self.logger.warning("connection gone{}", label)
except Exception:
self.logger.exception("send failed{}", label)
@@ -1087,6 +1180,8 @@ class WebSocketChannel(BaseChannel):
transcript_overrides: dict[str, Any] | None = None,
) -> bool:
"""Persist one canonical turn event and retain unsafe owners on failure."""
if not self._temporary_chats.should_persist_transcript(chat_id):
return True
persisted = self._transcripts.prepare_and_append(
chat_id,
event,
@@ -13,7 +13,9 @@ from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
OUTBOUND_META_AGENT_UI,
RUNTIME_CONTROL_SESSION_DISCARD,
OutboundMessage,
)
from nanobot.bus.outbound_events import (
@@ -32,11 +34,11 @@ from nanobot.channels.websocket.runtime import (
_is_valid_chat_id,
_parse_envelope,
_parse_inbound_payload,
publish_runtime_model_update,
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
from nanobot.session import webui_turns as wth
from nanobot.session.manager import SessionManager
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
@@ -193,6 +195,302 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
wth._WEBSOCKET_TURN_OWNERS.clear()
async def _new_temporary_chat(
channel: WebSocketChannel,
connection: AsyncMock,
) -> str:
channel._webui_connections.add(connection)
await channel._dispatch_envelope(
connection,
"webui-client",
{"type": "new_temporary_chat"},
)
payload = json.loads(connection.send.await_args.args[0])
assert payload["event"] == "attached"
assert payload["temporary"] is True
connection.send.reset_mock()
return payload["chat_id"]
@pytest.mark.asyncio
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
selected_project = tmp_path / "selected-project"
selected_project.mkdir()
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
workspace_path=tmp_path,
),
)
connection = AsyncMock()
connection.remote_address = ("127.0.0.1", 5000)
chat_id = await _new_temporary_chat(channel, connection)
upload = tmp_path / "temporary-upload.txt"
upload.write_text("private attachment", encoding="utf-8")
channel.gateway.media.store_inbound_attachments = MagicMock(
return_value=([str(upload)], None),
)
await channel._dispatch_envelope(
connection,
"webui-client",
{
"type": "message",
"chat_id": chat_id,
"content": "read this",
"media": [{"data_url": "data:text/plain;base64,cHJpdmF0ZQ=="}],
"cli_apps": [{"name": "drawio"}],
"workspace_scope": {
"project_path": str(selected_project),
"access_mode": "full",
},
"turn_id": "turn-1",
"webui": True,
},
)
inbound = bus.publish_inbound.await_args_list[0].args[0]
assert inbound.session_key == f"websocket:{chat_id}"
assert inbound.session_key_override == f"websocket:{chat_id}"
assert inbound.require_existing_session is True
assert inbound.metadata["cli_apps"] == [{"name": "drawio"}]
assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
"project_path": str(tmp_path.resolve()),
"access_mode": "restricted",
}
session = sessions.get_cached(inbound.session_key)
assert session is not None
assert session.policy.persist is False
assert upload.exists()
assert read_transcript_lines(inbound.session_key) == []
assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [
"message_accepted",
]
await channel._dispatch_envelope(
connection,
"webui-client",
{"type": "discard_temporary_chat", "chat_id": chat_id},
)
control = bus.publish_inbound.await_args_list[1].args[0]
assert bus.publish_inbound.await_count == 2
assert control.session_key == inbound.session_key
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
RUNTIME_CONTROL_SESSION_DISCARD
)
assert sessions.get_cached(inbound.session_key) is None
assert chat_id not in channel._subs
assert chat_id not in channel._conn_chats.get(connection, set())
assert not upload.exists()
assert read_transcript_lines(inbound.session_key) == []
@pytest.mark.asyncio
@pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"])
async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
connection.remote_address = ("127.0.0.1", 5000)
chat_id = await _new_temporary_chat(channel, connection)
await channel._dispatch_envelope(connection, "webui-client", {
"type": "message",
"chat_id": chat_id,
"content": content,
"webui": True,
})
assert bus.publish_inbound.await_count == 0
assert sessions.get_cached(f"websocket:{chat_id}") is not None
assert json.loads(connection.send.await_args.args[0])["detail"] == (
"temporary_chat_command_rejected"
)
@pytest.mark.asyncio
async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
workspace_path=tmp_path,
),
)
connection = AsyncMock()
chat_id = await _new_temporary_chat(channel, connection)
await channel._dispatch_envelope(
connection,
"webui-client",
{
"type": "message",
"chat_id": chat_id,
"content": "hello",
"webui": True,
},
)
await channel._cleanup_connection(connection)
session_key = f"websocket:{chat_id}"
control = bus.publish_inbound.await_args_list[-1].args[0]
assert control.session_key == session_key
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
RUNTIME_CONTROL_SESSION_DISCARD
)
assert sessions.get_cached(session_key) is None
assert chat_id not in channel._subs
@pytest.mark.asyncio
async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
await channel._dispatch_envelope(
connection,
"generic-websocket-client",
{"type": "new_temporary_chat"},
)
assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied"
assert sessions.list_sessions() == []
@pytest.mark.asyncio
async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
owner = AsyncMock()
other = AsyncMock()
channel._webui_connections.add(other)
chat_id = await _new_temporary_chat(channel, owner)
await channel._dispatch_envelope(
other,
"other-webui-client",
{
"type": "message",
"chat_id": chat_id,
"content": "claim it",
"webui": True,
},
)
assert json.loads(other.send.await_args.args[0])["detail"] == (
"temporary_chat_unavailable"
)
assert bus.publish_inbound.await_count == 0
assert sessions.get_cached(f"websocket:{chat_id}") is not None
@pytest.mark.asyncio
async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
chat_id = await _new_temporary_chat(channel, connection)
await channel._dispatch_envelope(
connection,
"webui-client",
{
"type": "set_workspace_scope",
"chat_id": chat_id,
"workspace_scope": {
"project_path": str(tmp_path),
"access_mode": "full",
},
},
)
payload = json.loads(connection.send.await_args.args[0])
assert payload["detail"] == "temporary_chat_workspace_rejected"
session = sessions.get_cached(f"websocket:{chat_id}")
assert session is not None
assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata
assert sessions.list_sessions() == []
@pytest.mark.asyncio
async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
channel._webui_connections.add(connection)
await channel._dispatch_envelope(
connection,
"webui-client",
{
"type": "message",
"chat_id": "temporary-looking-but-persistent",
"content": "/goal ordinary chat",
"webui": True,
},
)
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.require_existing_session is False
assert inbound.session_key_override is None
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
assert session is not None
assert session.policy.persist is True
@pytest.mark.asyncio
async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
)
connection = AsyncMock()
channel._attach(connection, "ordinary-chat")
await channel._dispatch_envelope(
connection,
"webui-client",
{"type": "discard_temporary_chat", "chat_id": "ordinary-chat"},
)
assert json.loads(connection.send.await_args.args[0])["detail"] == (
"temporary_chat_unavailable"
)
assert connection in channel._subs["ordinary-chat"]
assert "ordinary-chat" in channel._conn_chats[connection]
@pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn:
@@ -1105,8 +1403,14 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
await channel.send(bus.outbound.get_nowait())
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="*",
content="",
event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"),
)
)
payload = json.loads(mock_ws.send.call_args[0][0])
assert payload["event"] == "runtime_model_updated"
@@ -1141,26 +1445,6 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
chat_two.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
bus = MessageBus()
publish_runtime_model_update(
bus,
"openai/gpt-4.1",
"fast",
)
event = bus.outbound.get_nowait()
assert event.channel == "websocket"
assert event.chat_id == "*"
assert event.content == ""
assert event.metadata == {}
assert isinstance(event.event, RuntimeModelUpdatedEvent)
assert event.event.model == "openai/gpt-4.1"
assert event.event.model_preset == "fast"
@pytest.mark.asyncio
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
bus = MagicMock()
@@ -22,6 +22,7 @@ from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.media_api import (
b64url_decode,
b64url_encode,
sign_media_path,
)
from .ws_test_client import InProcessHttpChannel
@@ -84,8 +85,16 @@ def _fake_media_dir(root: Path):
return inner
def _sign_media_path(channel: WebSocketChannel, path: Path) -> str | None:
return sign_media_path(
path,
secret=channel.gateway.media.secret,
media_dir=channel.gateway.media._media_dir,
)
# ---------------------------------------------------------------------------
# gateway.media.sign_media_path: the URL minter
# media_api.sign_media_path: the URL minter
# ---------------------------------------------------------------------------
@@ -105,10 +114,10 @@ def test_sign_media_path_rejects_paths_outside_media_root(
media.mkdir()
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
assert channel.gateway.media.sign_media_path(outside) is None
assert _sign_media_path(channel, outside) is None
# Traversal via the media root is also rejected — the resolve() step
# normalises ``..`` out before the relative_to check.
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
assert _sign_media_path(channel, media / ".." / "secrets" / "cred.txt") is None
def test_sign_media_path_round_trips_via_hmac(
@@ -120,7 +129,7 @@ def test_sign_media_path_round_trips_via_hmac(
(media / "a.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url = channel.gateway.media.sign_media_path(media / "a.png")
url = _sign_media_path(channel, media / "a.png")
assert url is not None
assert url.startswith("/api/media/")
sig, payload = url[len("/api/media/"):].split("/", 1)
@@ -235,7 +244,7 @@ async def test_media_route_serves_signed_file(
channel = _ch(bus, port=29920)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -267,7 +276,7 @@ async def test_media_route_serves_video_byte_ranges(
channel = _ch(bus, port=29927)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -298,7 +307,7 @@ async def test_media_route_serves_suffix_video_byte_ranges(
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -326,7 +335,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
channel = _ch(bus, port=29929)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -358,7 +367,7 @@ async def test_media_route_rejects_bad_signature(
channel = _ch(bus, port=29921)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
good = channel.gateway.media.sign_media_path(media / "f.png")
good = _sign_media_path(channel, media / "f.png")
assert good is not None
_, payload = good[len("/api/media/"):].split("/", 1)
# Forge a sig with a *different* secret.
@@ -423,7 +432,7 @@ async def test_media_route_404s_missing_file(
channel = _ch(bus, port=29923)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start())
@@ -480,7 +489,7 @@ async def test_media_route_serves_svg_with_strict_csp(
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
+16
View File
@@ -12,7 +12,9 @@ from nanobot.config_base import Base
from nanobot.cron.types import CronSchedule
if TYPE_CHECKING:
from nanobot.agent.tools.browser_tool import BrowserToolConfig
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
@@ -399,6 +401,16 @@ class ToolsConfig(Base):
"""
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
browser: BrowserToolConfig = Field(
default_factory=lambda: _lazy_default(
"nanobot.agent.tools.browser_tool", "BrowserToolConfig"
)
)
computer_use: ComputerUseToolConfig = Field(
default_factory=lambda: _lazy_default(
"nanobot.agent.tools.computer_use", "ComputerUseToolConfig"
)
)
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
@@ -670,7 +682,9 @@ def _resolve_tool_config_refs() -> None:
"""
import sys
from nanobot.agent.tools.browser_tool import BrowserToolConfig
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.computer_use import ComputerUseToolConfig
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
@@ -680,6 +694,8 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace
mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.BrowserToolConfig = BrowserToolConfig # type: ignore[attr-defined]
mod.ComputerUseToolConfig = ComputerUseToolConfig # type: ignore[attr-defined]
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
-7
View File
@@ -25,9 +25,6 @@ from nanobot.cron.types import (
CronSchedule,
CronStore,
)
from nanobot.utils.run_records import (
safe_run_record_name,
)
from nanobot.utils.run_records import (
write_run_record as write_automation_run_record,
)
@@ -440,10 +437,6 @@ class CronService:
tmp_path.unlink(missing_ok=True)
raise
@staticmethod
def _safe_run_record_name(run_id: str) -> str:
return safe_run_record_name(run_id)
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
"""Write an internal audit record for one cron execution."""
write_automation_run_record(self._run_records_dir, run_id, record)
-6
View File
@@ -7,7 +7,6 @@ from typing import Any, Mapping
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
@@ -63,11 +62,6 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
return value if isinstance(value, str) and value else None
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a cron turn."""
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
def is_bound_cron_job(job: CronJob) -> bool:
"""True for session-bound cron jobs with complete delivery context."""
payload = job.payload
+69 -1
View File
@@ -671,6 +671,73 @@ class OpenAICompatProvider(LLMProvider):
dumped = str(content)
return dumped or "(empty)"
@classmethod
def _move_tool_images_to_user(
cls,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Adapt multimodal tool results to Chat Completions' text-only tool role."""
updated: list[dict[str, Any]] = []
pending_images: list[dict[str, Any]] = []
def flush_images(next_message: dict[str, Any] | None = None) -> None:
if not pending_images:
if next_message is not None:
updated.append(next_message)
return
content: list[dict[str, Any]] = [
*pending_images,
{"type": "text", "text": "Images returned by the preceding tool call(s)."},
]
pending_images.clear()
if next_message is not None and next_message.get("role") == "user":
existing = next_message.get("content")
if isinstance(existing, str):
content.append({"type": "text", "text": existing})
elif isinstance(existing, list):
content.extend(cast(list[dict[str, Any]], existing))
updated.append({**next_message, "content": content})
else:
updated.append({"role": "user", "content": content})
if next_message is not None:
updated.append(next_message)
for message in messages:
content = message.get("content")
if message.get("role") == "tool" and isinstance(content, list):
blocks = cast(list[object], content)
images: list[dict[str, Any]] = []
text_blocks: list[object] = []
for block in blocks:
if isinstance(block, dict):
block_data = cast(dict[str, Any], block)
image_url = block_data.get("image_url")
if block_data.get("type") == "image_url" and isinstance(
image_url, dict
):
images.append({"type": "image_url", "image_url": image_url})
continue
text_blocks.append(block_data)
else:
text_blocks.append(block)
if images:
updated.append({
**message,
"content": (
cls._coerce_content_to_string(text_blocks)
if text_blocks
else "(image returned)"
),
})
pending_images.extend(images)
continue
if message.get("role") != "tool":
flush_images(message)
else:
updated.append(message)
flush_images()
return updated
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Strip non-standard keys, normalize tool_call IDs."""
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
@@ -824,9 +891,10 @@ class OpenAICompatProvider(LLMProvider):
model_name = self._request_model_name(model_name)
sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages))
kwargs: dict[str, Any] = {
"model": model_name,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
"messages": self._move_tool_images_to_user(sanitized_messages),
}
# GPT-5 and reasoning models (o1/o3/o4) reject temperature when
+32 -5
View File
@@ -11,7 +11,7 @@ from copy import deepcopy
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Protocol, TypedDict, cast
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
from weakref import WeakValueDictionary
from loguru import logger
@@ -147,6 +147,15 @@ class RetentionResult:
already_consolidated_count: int
@dataclass(frozen=True)
class SessionPolicy:
"""Runtime rules that do not belong in durable session data."""
persist: bool = True
log_content: bool = True
disabled_tools: frozenset[str] = frozenset()
@dataclass
class Session:
"""A conversation session."""
@@ -158,6 +167,7 @@ class Session:
metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
provider_state: ProviderConversationState | None = field(default=None, repr=False)
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
def __post_init__(self) -> None:
if not isinstance(cast(object, self.metadata), dict):
@@ -1079,6 +1089,24 @@ class SessionManager:
self._remember(session)
return session
def get_or_create_transient(
self,
key: str,
*,
disabled_tools: Collection[str] = (),
) -> Session:
"""Return a fresh, non-persistent session without loading history."""
policy = SessionPolicy(
persist=False,
log_content=False,
disabled_tools=frozenset(disabled_tools),
)
session = self.get_cached(key)
if session is None or session.policy != policy:
session = Session(key=key, policy=policy)
self._remember(session)
return session
def _load(self, key: str) -> Session | None:
return self._store.load(key)
@@ -1086,12 +1114,11 @@ class SessionManager:
"""Attempt to recover a session from a corrupt JSONL file."""
return self._jsonl_store.repair(key, path=path)
@staticmethod
def _session_payload(session: Session) -> SessionPayload:
return JsonlSessionStore.session_payload(session)
def save(self, session: Session, *, fsync: bool = False) -> None:
"""Persist a session and retain it in the cache."""
if not session.policy.persist:
return
archiver = self._file_cap_archiver
if archiver is not None:
session.enforce_file_cap(
+6
View File
@@ -334,6 +334,12 @@ def clear_websocket_turn_if_current(
return False
def clear_websocket_turns(chat_id: str) -> None:
"""Forget every in-process turn projection for a discarded chat."""
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
_sync_websocket_turn_projection(chat_id)
def build_bus_progress_callback(
bus: MessageBus,
msg: InboundMessage,
-11
View File
@@ -6,7 +6,6 @@ from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
@@ -50,13 +49,3 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
return None
value = trigger.get("delivery_id")
return value if isinstance(value, str) and value else None
def local_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a local trigger turn."""
return automation_history_overrides_for_spec(
metadata,
LOCAL_TRIGGER_AUTOMATION_SPEC,
)
-29
View File
@@ -11,35 +11,6 @@ from loguru import logger
from nanobot.utils.helpers import detect_image_mime
# Supported file extensions for text extraction
SUPPORTED_EXTENSIONS: set[str] = {
# Document formats
".pdf",
".docx",
".xlsx",
".pptx",
# Text formats
".txt",
".md",
".csv",
".json",
".xml",
".html",
".htm",
".log",
".yaml",
".yml",
".toml",
".ini",
".cfg",
# Image formats (for future OCR support)
".png",
".jpg",
".jpeg",
".gif",
".webp",
}
_MAX_TEXT_LENGTH = 200_000
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
-18
View File
@@ -274,24 +274,6 @@ def _text_line_count(text: str) -> int:
return line_count if last_was_newline else line_count + 1
def prepare_file_edit_tracker(
*,
call_id: str,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> FileEditTracker | None:
trackers = prepare_file_edit_trackers(
call_id=call_id,
tool_name=tool_name,
tool=tool,
workspace=workspace,
params=params,
)
return trackers[0] if trackers else None
def prepare_file_edit_trackers(
*,
call_id: str,
+2 -56
View File
@@ -5,14 +5,13 @@ from __future__ import annotations
import io
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, cast
from typing import TYPE_CHECKING, cast
from loguru import logger
if TYPE_CHECKING:
from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry
from dulwich.objects import Blob, Commit, ObjectID, Tree
from dulwich.refs import Ref
from dulwich.repo import Repo
@@ -45,25 +44,6 @@ class CommitInfo:
return f"{header}\n(no file changes)"
@dataclass
class LineAge:
"""Age of a single line based on git blame."""
age_days: int # days since last modification
def _compute_line_ages(
annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]],
) -> list[LineAge]:
"""Convert annotate results to per-line ages."""
now = datetime.now(tz=timezone.utc).date()
ages: list[LineAge] = []
for (commit, _tree_entry), _line_bytes in annotated:
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
ages.append(LineAge(age_days=(now - dt).days))
return ages
class GitStore:
"""Git-backed version control for memory files."""
@@ -293,33 +273,6 @@ class GitStore:
except Exception as exc:
raise GitStoreError("Git log failed") from exc
def line_ages(self, file_path: str) -> list[LineAge]:
"""Compute the age of each line in a tracked file via git blame.
Returns one LineAge per line, in order.
Returns an empty list if the repo is not initialized or the file is
empty. Annotation failures raise :class:`GitStoreError`.
"""
if not self.is_initialized():
return []
target = self._workspace / file_path
if not target.exists() or target.stat().st_size == 0:
return []
try:
from dulwich import porcelain
annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception as exc:
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
if not annotated:
return []
return _compute_line_ages(annotated)
def diff_commits(self, sha1: str, sha2: str) -> str:
"""Show diff between two commits."""
if not self.is_initialized():
@@ -461,13 +414,6 @@ class GitStore:
commit = cast("Commit", commit_obj)
return cast("Tree", repo[commit.tree])
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
"""Find a commit by short SHA prefix match."""
for c in self.log(max_entries=max_entries):
if c.sha.startswith(short_sha):
return c
return None
def show_commit_diff(
self,
short_sha: str,
+12 -17
View File
@@ -351,23 +351,12 @@ def timestamp() -> str:
return datetime.now().isoformat()
def current_time_str(timezone: str | None = None) -> str:
"""Return the current time string."""
from zoneinfo import ZoneInfo
tz = ZoneInfo(timezone) if timezone else None
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
offset = now.strftime("%z")
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
tz_name = timezone or (time.strftime("%Z") or "UTC")
return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})"
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
_TOOL_RESULT_PREVIEW_CHARS = 1200
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
_TOOL_RESULT_RETENTION_SECS = 7 * 24 * 60 * 60
_TOOL_RESULT_MAX_BUCKETS = 32
_IMAGE_TOKEN_ESTIMATE = 2048
_TRUNCATED_SUFFIX = "\n... (truncated)"
@@ -688,6 +677,7 @@ def _estimate_prompt_tokens_with_source(
reasoning_content, tool_call_id, name, plus per-message framing overhead.
"""
parts: list[str] = []
image_tokens = 0
for msg in messages:
content = msg.get("content")
if isinstance(content, str):
@@ -699,6 +689,8 @@ def _estimate_prompt_tokens_with_source(
text = part.get("text", "")
if isinstance(text, str) and text:
parts.append(text)
elif part is not None and part.get("type") in {"image_url", "input_image"}:
image_tokens += _IMAGE_TOKEN_ESTIMATE
tc = msg.get("tool_calls")
if tc:
@@ -721,7 +713,7 @@ def _estimate_prompt_tokens_with_source(
_estimate_tools_tokens(enc, tools, leading_separator=bool(parts)) if tools else 0
)
message_tokens = len(enc.encode(message_payload)) if message_payload else 0
return message_tokens + tool_tokens + per_message_overhead, "tiktoken"
return message_tokens + image_tokens + tool_tokens + per_message_overhead, "tiktoken"
except Exception:
tool_payload = (
("\n" if message_payload else "") + json.dumps(tools, ensure_ascii=False)
@@ -730,7 +722,7 @@ def _estimate_prompt_tokens_with_source(
)
payload = message_payload + tool_payload
estimated = len(payload.encode("utf-8"))
return estimated + per_message_overhead, "heuristic"
return estimated + image_tokens + per_message_overhead, "heuristic"
def estimate_prompt_tokens(
@@ -746,6 +738,7 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
"""Estimate prompt tokens contributed by one persisted message."""
content = message.get("content")
parts: list[str] = []
image_tokens = 0
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
@@ -755,6 +748,8 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
text = part.get("text", "")
if isinstance(text, str) and text:
parts.append(text)
elif part is not None and part.get("type") in {"image_url", "input_image"}:
image_tokens += _IMAGE_TOKEN_ESTIMATE
else:
parts.append(json.dumps(raw_part, ensure_ascii=False))
elif content is not None:
@@ -772,13 +767,13 @@ def estimate_message_tokens(message: dict[str, Any]) -> int:
parts.append(rc)
payload = "\n".join(parts)
if not payload:
if not payload and not image_tokens:
return 4
try:
enc = _get_token_encoding()
return max(4, len(enc.encode(payload)) + 4)
return max(4, len(enc.encode(payload)) + image_tokens + 4)
except Exception:
return max(4, len(payload.encode("utf-8")) + 4)
return max(4, len(payload.encode("utf-8")) + image_tokens + 4)
def estimate_prompt_tokens_chain(
+9
View File
@@ -11,6 +11,7 @@ from loguru import logger as default_logger
from nanobot.webui.gateway_tokens import GatewayTokenStore
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
from nanobot.webui.media_gateway import WebUIMediaGateway
from nanobot.webui.temporary_chats import WebUITemporaryChats
from nanobot.webui.transcript import WebUITranscriptRecorder
from nanobot.webui.workspaces import WebUIWorkspaceController
from nanobot.webui.ws_http import GatewayHTTPHandler
@@ -33,6 +34,7 @@ class GatewayServices:
ingress: WebUIIngressPolicy
transcripts: WebUITranscriptRecorder
workspaces: WebUIWorkspaceController
temporary_chats: WebUITemporaryChats
session_manager: SessionManager | None
cron_service: CronService | None
local_trigger_store: LocalTriggerStore | None
@@ -82,6 +84,12 @@ def build_gateway_services(
default_workspace=workspace_path,
default_restrict_to_workspace=default_restrict_to_workspace,
)
temporary_chats = WebUITemporaryChats(
bus=bus,
session_manager=session_manager,
workspaces=workspaces,
logger=logger,
)
http = GatewayHTTPHandler(
config=config,
session_manager=session_manager,
@@ -112,6 +120,7 @@ def build_gateway_services(
ingress=ingress,
transcripts=transcripts,
workspaces=workspaces,
temporary_chats=temporary_chats,
session_manager=session_manager,
cron_service=cron_service,
local_trigger_store=local_trigger_store,
-8
View File
@@ -18,7 +18,6 @@ from nanobot.webui.attachment_ingress import (
from nanobot.webui.ingress_policy import AttachmentIngressLimits
from nanobot.webui.media_api import (
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
signed_media_attachments,
)
@@ -71,13 +70,6 @@ class WebUIMediaGateway:
media_dir=self._media_dir,
)
def sign_media_path(self, abs_path: Path) -> str | None:
return sign_media_path(
abs_path,
secret=self.secret,
media_dir=self._media_dir,
)
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
return sign_or_stage_media_path(
path,
+29
View File
@@ -1261,6 +1261,11 @@ def settings_payload(
"use_jina_reader": config.tools.web.fetch.use_jina_reader,
},
},
"computer_use": {
"browser_enabled": config.tools.browser.enable,
"enabled": config.tools.computer_use.enable,
"backend": config.tools.computer_use.backend,
},
"api": {
"host": config.api.host,
"port": config.api.port,
@@ -2045,6 +2050,30 @@ def update_network_safety_settings(query: QueryParams) -> dict[str, Any]:
return settings_payload(requires_restart=changed)
def update_computer_use_settings(query: QueryParams) -> dict[str, Any]:
raw_browser = _query_first_alias(query, "browser_enabled", "browserEnabled")
raw_computer = _query_first_alias(query, "enabled", "computerEnabled")
if raw_browser is None and raw_computer is None:
raise WebUISettingsError("browser_enabled or enabled is required")
config = load_config()
changed = False
if raw_browser is not None:
browser_enabled = _parse_bool(raw_browser, "browser_enabled")
if config.tools.browser.enable != browser_enabled:
config.tools.browser.enable = browser_enabled
changed = True
if raw_computer is not None:
computer_enabled = _parse_bool(raw_computer, "enabled")
if config.tools.computer_use.enable != computer_enabled:
config.tools.computer_use.enable = computer_enabled
changed = True
if changed:
save_config(config)
return settings_payload(requires_restart=changed)
def update_web_search_settings(query: QueryParams) -> dict[str, Any]:
provider_name = (_query_first(query, "provider") or "").strip().lower()
provider_option = _WEB_SEARCH_PROVIDER_BY_NAME.get(provider_name)
+18
View File
@@ -64,6 +64,7 @@ from nanobot.webui.settings_api import (
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_computer_use_settings,
update_image_generation_settings,
update_model_call_order,
update_model_configuration,
@@ -174,6 +175,8 @@ class WebUISettingsRouter:
return await self._handle_settings_provider_oauth(request, "logout")
if path == "/api/settings/web-search/update":
return self._handle_settings_web_search_update(request)
if path == "/api/settings/computer-use/update":
return self._handle_settings_computer_use_update(request)
if path == "/api/settings/api-service":
return self._handle_settings_api_service(request)
if path == "/api/settings/api-service/start":
@@ -506,6 +509,21 @@ class WebUISettingsRouter:
return self._error_response(e.status, e.message)
return self._json_response(self._with_restart_state(payload, section="browser"))
def _handle_settings_computer_use_update(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
query = self._query(request)
try:
payload = update_computer_use_settings(query)
except WebUISettingsError as e:
return self._error_response(e.status, e.message)
if payload.get("requires_restart"):
if "browser_enabled" in query or "browserEnabled" in query:
self._restart_sections.add("browser")
if "enabled" in query or "computerEnabled" in query:
self._restart_sections.add("runtime")
return self._json_response(self._with_restart_state(payload))
def _handle_settings_api_service(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
+218
View File
@@ -0,0 +1,218 @@
"""Connection-owned Temporary Chat behavior for the WebUI."""
from __future__ import annotations
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.workspace_access import WorkspaceScope
from nanobot.session.manager import Session, SessionManager
from nanobot.webui.workspaces import WebUIWorkspaceController
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
"create_goal",
"update_goal",
"spawn",
"cron",
})
_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"})
class TemporaryChatError(ValueError):
"""A stable WebUI protocol error for a Temporary Chat operation."""
def __init__(self, detail: str) -> None:
super().__init__(detail)
self.detail = detail
@dataclass(frozen=True)
class TemporaryChatMessagePolicy:
"""Server-owned message rules for one active Temporary Chat."""
session_key: str
workspace_scope: WorkspaceScope
require_existing_session: bool = True
hydrate_transcript: bool = False
persist_transcript: bool = False
class WebUITemporaryChats:
"""Own Temporary Chat creation, policy, attachments, and disposal."""
def __init__(
self,
*,
bus: MessageBus,
session_manager: SessionManager | None,
workspaces: WebUIWorkspaceController,
logger: Any,
channel_name: str = "websocket",
) -> None:
self._bus = bus
self._sessions = session_manager
self._workspaces = workspaces
self._logger = logger
self._channel_name = channel_name
self._owners: dict[str, object] = {}
self._owner_chat_ids: dict[object, set[str]] = {}
# Keep active sessions alive if the bounded manager cache evicts them
# between WebUI turns. SessionPolicy remains the authority below.
self._active_sessions: dict[str, Session] = {}
# Retain policy-derived tombstones until shutdown so late outbound
# events cannot create a durable transcript after a chat is discarded.
self._known_transient_chat_ids: set[str] = set()
self._media_paths: dict[str, set[str]] = {}
def _session_key(self, chat_id: str) -> str:
return f"{self._channel_name}:{chat_id}"
def _cached_session_is_transient(self, chat_id: str) -> bool:
if self._sessions is None:
return False
session = self._sessions.get_cached(self._session_key(chat_id))
return session is not None and not session.policy.persist
def create(self, owner: object, *, trusted_webui: bool) -> str:
"""Create a server-identified chat owned by one authenticated WebUI connection."""
if not trusted_webui:
raise TemporaryChatError("access_denied")
if self._sessions is None:
raise TemporaryChatError("temporary_chat_unavailable")
chat_id = str(uuid.uuid4())
session = self._sessions.get_or_create_transient(
self._session_key(chat_id),
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
)
if session.policy.persist:
raise RuntimeError("Temporary Chat must use a non-persistent session policy")
self._owners[chat_id] = owner
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
self._active_sessions[chat_id] = session
self._known_transient_chat_ids.add(chat_id)
return chat_id
def message_policy(
self,
owner: object,
chat_id: str,
content: str,
) -> TemporaryChatMessagePolicy | None:
"""Return Temporary Chat rules, or ``None`` for an ordinary chat."""
if not self._cached_session_is_transient(chat_id):
if chat_id in self._known_transient_chat_ids:
raise TemporaryChatError("temporary_chat_unavailable")
return None
if self._owners.get(chat_id) is not owner or self._sessions is None:
raise TemporaryChatError("temporary_chat_unavailable")
session = self._sessions.get_cached(self._session_key(chat_id))
if session is None:
raise TemporaryChatError("temporary_chat_unavailable")
command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else ""
if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS:
raise TemporaryChatError("temporary_chat_command_rejected")
return TemporaryChatMessagePolicy(
session_key=self._session_key(chat_id),
workspace_scope=self._workspaces.restricted_default_scope(),
)
def validate_attach(self, chat_id: str) -> None:
"""Reject attempts to recover a non-persistent session."""
if not self._cached_session_is_transient(chat_id):
if chat_id in self._known_transient_chat_ids:
raise TemporaryChatError("temporary_chat_unavailable")
return
raise TemporaryChatError("temporary_chat_unavailable")
def validate_workspace_update(self, chat_id: str) -> None:
"""Prevent non-persistent sessions from acquiring durable workspace state."""
if self._cached_session_is_transient(chat_id):
raise TemporaryChatError("temporary_chat_workspace_rejected")
if chat_id in self._known_transient_chat_ids:
raise TemporaryChatError("temporary_chat_unavailable")
def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None:
if not paths:
return
if self._owners.get(chat_id) is not owner:
raise TemporaryChatError("temporary_chat_unavailable")
self._media_paths.setdefault(chat_id, set()).update(paths)
def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]:
return tuple(self._owner_chat_ids.get(owner, ()))
def owns(self, owner: object, chat_id: str) -> bool:
return self._owners.get(chat_id) is owner
def should_persist_transcript(self, chat_id: str) -> bool:
"""Apply the session policy and retain it for late events after disposal."""
return (
not self._cached_session_is_transient(chat_id)
and chat_id not in self._known_transient_chat_ids
)
def _discard_media(self, chat_id: str) -> None:
for raw_path in self._media_paths.pop(chat_id, set()):
try:
Path(raw_path).unlink(missing_ok=True)
except OSError:
self._logger.warning("failed to remove a temporary WebUI attachment")
def _forget_owner(self, owner: object, chat_id: str) -> None:
self._owners.pop(chat_id, None)
chat_ids = self._owner_chat_ids.get(owner)
if chat_ids is None:
return
chat_ids.discard(chat_id)
if not chat_ids:
self._owner_chat_ids.pop(owner, None)
async def discard(self, owner: object, chat_id: str) -> None:
"""Forget one owned chat and cancel any active work through the message bus."""
if (
not self._cached_session_is_transient(chat_id)
or self._owners.get(chat_id) is not owner
):
raise TemporaryChatError("temporary_chat_unavailable")
session_key = self._session_key(chat_id)
self._forget_owner(owner, chat_id)
self._active_sessions.pop(chat_id, None)
self._discard_media(chat_id)
if self._sessions is not None:
self._sessions.invalidate(session_key)
await self._bus.publish_inbound(
InboundMessage(
channel=self._channel_name,
sender_id="webui",
chat_id=chat_id,
content="",
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
},
session_key_override=session_key,
)
)
def close(self) -> None:
"""Release process-local resources during gateway shutdown."""
for chat_id in tuple(self._owners):
self._discard_media(chat_id)
if self._sessions is not None:
self._sessions.invalidate(self._session_key(chat_id))
self._owners.clear()
self._owner_chat_ids.clear()
self._active_sessions.clear()
self._known_transient_chat_ids.clear()
-29
View File
@@ -1313,21 +1313,6 @@ def _recover_incomplete_turns(
return recovered
def recover_incomplete_turns_from_session(
lines: list[dict[str, Any]],
session_messages: list[dict[str, Any]] | None,
*,
session_key: str,
) -> list[dict[str, Any]]:
"""Recover marked transcript answers only when one durable session turn matches."""
if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines):
return lines
session_turns = _session_backfill_turns(session_key, session_messages)
if not session_turns:
return lines
return _recover_incomplete_turns(lines, session_turns)
def _with_backfilled_user(
records: list[dict[str, Any]],
user_event: dict[str, Any],
@@ -1365,20 +1350,6 @@ def _inject_missing_user_events(
return out
def inject_missing_user_events_from_session(
session_key: str,
lines: list[dict[str, Any]],
session_messages: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
if not lines or not session_messages or not _needs_user_event_backfill(lines):
return lines
session_turns = _session_backfill_turns(session_key, session_messages)
if not session_turns:
return lines
return _inject_missing_user_events(lines, session_turns)
def _format_tool_call_trace(call: Any) -> str | None:
if not call or not isinstance(call, dict):
return None
+8
View File
@@ -191,6 +191,14 @@ class WebUIWorkspaceController:
self._default_restrict_to_workspace,
)
def restricted_default_scope(self) -> WorkspaceScope:
"""Return the default workspace with access restricted for this request."""
return build_workspace_scope(
self._default_workspace,
"restricted",
source_channel=_WEBUI_SCOPE_CHANNEL,
)
def _scope_from_metadata_value(
self,
raw_scope: object,
+5
View File
@@ -88,6 +88,11 @@ pdf = [
olostep = [
"olostep>=0.1.0; python_version < '3.14'",
]
computer-use = [
"pyautogui>=0.9.54",
"pillow>=10.0.0",
"playwright>=1.48.0",
]
dev = [
"pytest>=9.0.0,<10.0.0",
"pytest-asyncio>=1.3.0,<2.0.0",
-172
View File
@@ -1,172 +0,0 @@
import json
import shutil
from pathlib import Path
import pytest
from nanobot.agent.agent_plugins import AGENT_PLUGIN_SCHEMA, discover_agent_plugin_skills
from nanobot.agent.skills import SkillsLoader
def _write_skill(root: Path, name: str, *, description: str = "Plugin skill.") -> Path:
skill = root / "skills" / name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n",
encoding="utf-8",
)
return skill
def _write_plugin(
workspace: Path,
directory: str,
*,
name: str | None = None,
manifest: dict[str, object] | None = None,
) -> Path:
root = workspace / "plugins" / directory
root.mkdir(parents=True)
payload = manifest or {
"$schema": AGENT_PLUGIN_SCHEMA,
"name": name or directory,
}
(root / "plugin.json").write_text(json.dumps(payload), encoding="utf-8")
return root
def test_skills_loader_discovers_agent_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes", description="Draft release notes from changes.")
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == [
{
"name": "release-notes",
"path": str(plugin / "skills" / "release-notes" / "SKILL.md"),
"source": "plugin",
"plugin": "acme-tools",
}
]
assert loader.get_explicitly_invoked_skills("Use $release-notes") == ["release-notes"]
assert "Draft release notes" in (loader.load_skill("release-notes") or "")
assert "### Agent Plugin skills" in loader.build_skills_summary()
assert "`acme-tools/skills/release-notes/SKILL.md`" in loader.build_skills_summary()
def test_skills_loader_sees_plugin_installed_after_startup(tmp_path: Path) -> None:
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert loader.list_skills() == []
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "release-notes")
assert [entry["name"] for entry in loader.list_skills()] == ["release-notes"]
shutil.rmtree(plugin)
assert loader.list_skills() == []
assert loader.build_skills_summary() == ""
def test_agent_plugin_skills_are_direct_children_only(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "acme-tools")
_write_skill(plugin, "direct")
nested = plugin / "skills" / "group" / "nested"
nested.mkdir(parents=True)
(nested / "SKILL.md").write_text(
"---\nname: nested\ndescription: Nested skill.\n---\n",
encoding="utf-8",
)
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["direct"]
@pytest.mark.parametrize(
"manifest",
[
{"$schema": "https://agent-plugins.org/schemas/2.0.0/plugin.schema.json", "name": "demo"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "Bad-Name"},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "author": None},
{"$schema": AGENT_PLUGIN_SCHEMA, "name": "demo", "keywords": None},
],
)
def test_invalid_agent_plugin_manifest_is_skipped(
tmp_path: Path,
manifest: dict[str, object],
) -> None:
plugin = _write_plugin(tmp_path, "demo", manifest=manifest)
_write_skill(plugin, "example")
assert discover_agent_plugin_skills(tmp_path) == []
def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: Path) -> None:
plugin = _write_plugin(
tmp_path,
"demo",
manifest={
"$schema": AGENT_PLUGIN_SCHEMA,
"name": "demo",
"futureField": True,
"extensions": "invalid but non-fatal",
},
)
_write_skill(plugin, "example")
assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"]
@pytest.mark.parametrize(
("skill_name", "frontmatter"),
[
("wrong-directory", "name: another\ndescription: Mismatch."),
("missing-description", "name: missing-description"),
("Bad-Name", "name: Bad-Name\ndescription: Invalid name."),
],
)
def test_invalid_agent_skill_is_skipped(
tmp_path: Path,
skill_name: str,
frontmatter: str,
) -> None:
plugin = _write_plugin(tmp_path, "demo")
skill = plugin / "skills" / skill_name
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(f"---\n{frontmatter}\n---\n", encoding="utf-8")
assert discover_agent_plugin_skills(tmp_path) == []
def test_workspace_skill_overrides_plugin_skill(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
_write_skill(plugin, "shared", description="Plugin version.")
workspace_skill = tmp_path / "skills" / "shared"
workspace_skill.mkdir(parents=True)
(workspace_skill / "SKILL.md").write_text(
"---\nname: shared\ndescription: Workspace version.\n---\n",
encoding="utf-8",
)
loader = SkillsLoader(tmp_path, builtin_skills_dir=tmp_path / "builtin")
assert [entry["source"] for entry in loader.list_skills()] == ["workspace"]
assert "Workspace version" in (loader.load_skill("shared") or "")
def test_plugin_skill_symlink_cannot_escape_plugin_root(tmp_path: Path) -> None:
plugin = _write_plugin(tmp_path, "demo")
outside = tmp_path / "outside"
_write_skill(outside, "escaped")
skills_root = plugin / "skills"
skills_root.mkdir()
try:
(skills_root / "escaped").symlink_to(
outside / "skills" / "escaped",
target_is_directory=True,
)
except OSError as exc:
pytest.skip(f"directory symlink unavailable: {exc}")
assert discover_agent_plugin_skills(tmp_path) == []
+25
View File
@@ -1,6 +1,13 @@
from nanobot.agent.context_governance import ContextGovernor
def _image_result(label: str) -> list[dict]:
return [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{label}"}},
{"type": "text", "text": label},
]
def _assistant_tool_call(call_id: str) -> dict:
return {
"role": "assistant",
@@ -37,3 +44,21 @@ def test_drop_orphan_tool_results_drops_duplicate_tool_result() -> None:
tool_results = [m for m in result if m.get("role") == "tool"]
assert len(tool_results) == 1
assert tool_results[0]["content"] == "first"
def test_drop_stale_visual_tool_images_keeps_latest_per_tool() -> None:
messages = [
{"role": "tool", "name": "computer_use", "content": _image_result("history")},
{"role": "tool", "name": "computer_use", "content": _image_result("old")},
{"role": "tool", "name": "browser", "content": _image_result("browser")},
{"role": "tool", "name": "computer_use", "content": _image_result("latest")},
]
result = ContextGovernor.drop_stale_visual_tool_images(messages, start_index=1)
assert result is not messages
assert result[0]["content"] == messages[0]["content"]
assert [block["type"] for block in result[1]["content"]] == ["text", "text"]
assert result[2]["content"] == messages[2]["content"]
assert result[3]["content"] == messages[3]["content"]
assert messages[1]["content"][0]["type"] == "image_url"
-13
View File
@@ -169,19 +169,6 @@ class TestDiffCommits:
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
class TestFindCommit:
def test_finds_by_prefix(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("v2")
found = git_ready.find_commit(sha[:4])
assert found is not None
assert found.sha == sha
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.find_commit("deadbeef") is None
class TestShowCommitDiff:
def test_returns_commit_with_diff(self, git_ready):
ws = git_ready._workspace
+165
View File
@@ -0,0 +1,165 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import (
INBOUND_META_RUNTIME_CONTROL,
RUNTIME_CONTROL_SESSION_DISCARD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.session.keys import UNIFIED_SESSION_KEY
def _message(key: str, content: str) -> InboundMessage:
return InboundMessage(
channel="websocket",
sender_id="user",
chat_id=key.removeprefix("websocket:"),
content=content,
session_key_override=key,
require_existing_session=True,
)
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
provider.chat_with_retry = AsyncMock(
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
)
return AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
cron_service=MagicMock(),
**kwargs,
)
@pytest.mark.asyncio
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
loop = _loop(tmp_path, ["first answer", "second answer"])
loop.context.memory.write_memory("private durable memory")
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
key = "websocket:transient-test"
loop.sessions.get_or_create_transient(
key,
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
)
await loop._process_message(_message(key, "first question"))
await loop._process_message(_message(key, "second question"))
calls = loop.provider.chat_with_retry.await_args_list
assert "private durable memory" not in str(calls[0].kwargs["messages"])
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
assert "read_session" in tool_names
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
assert "first answer" in str(calls[1].kwargs["messages"])
session = loop.sessions.get_cached(key)
assert session is not None
assert [message["role"] for message in session.messages] == [
"user",
"assistant",
"user",
"assistant",
]
assert loop.sessions.read_session_file(key) is None
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
@pytest.mark.asyncio
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
loop = _loop(tmp_path, ["private answer"], unified_session=True)
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
durable.add_message("user", "durable question")
loop.sessions.save(durable)
key = "websocket:transient-unified"
transient = loop.sessions.get_or_create_transient(key)
await loop._dispatch(_message(key, "private question"))
assert [message["content"] for message in transient.messages] == [
"private question",
"private answer",
]
assert [message["content"] for message in durable.messages] == ["durable question"]
assert loop.sessions.read_session_file(key) is None
@pytest.mark.asyncio
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
loop = _loop(tmp_path, [])
key = "websocket:transient-stale"
loop.sessions.get_or_create_transient(key)
loop.sessions.invalidate(key)
with pytest.raises(RuntimeError, match="required session is not active"):
await loop._process_message(_message(key, "stale private message"))
loop.provider.chat_with_retry.assert_not_awaited()
assert loop.sessions.read_session_file(key) is None
@pytest.mark.asyncio
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
provider_started = asyncio.Event()
async def block_provider(**_kwargs: object) -> LLMResponse:
provider_started.set()
await asyncio.Event().wait()
raise AssertionError("provider blocker unexpectedly released")
loop = _loop(tmp_path, [])
async def wait_for_discard(key: str) -> None:
while loop.sessions.get_cached(key) is not None or key in loop._discarding_sessions:
await asyncio.sleep(0)
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
terminate_exec_sessions = AsyncMock(return_value=1)
monkeypatch.setattr(
loop._exec_session_manager,
"terminate_by_owner",
terminate_exec_sessions,
)
key = "websocket:transient-cancelled"
loop.sessions.get_or_create_transient(
key,
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
)
run_task = asyncio.create_task(loop.run())
await loop.bus.publish_inbound(_message(key, "private"))
await asyncio.wait_for(provider_started.wait(), timeout=2)
active_task = next(iter(loop._active_tasks[key]))
await loop.bus.publish_inbound(
InboundMessage(
channel="websocket",
sender_id="webui",
chat_id="transient-cancelled",
content="",
metadata={
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
},
session_key_override=key,
)
)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(active_task, timeout=2)
await asyncio.wait_for(wait_for_discard(key), timeout=2)
assert loop.sessions.get_cached(key) is None
terminate_exec_sessions.assert_awaited_once_with(key)
loop.stop()
await loop.bus.publish_inbound(_message(key, "wake"))
await asyncio.wait_for(run_task, timeout=2)
+11 -2
View File
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.automation_turns import publish_next_deferred_turn
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -1047,7 +1048,11 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
assert loop._cron_turns.deferred_queues[session_key] == [msg]
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
await loop._cron_turns.publish_next_deferred(session_key)
await publish_next_deferred_turn(
deferred_queues=loop._cron_turns.deferred_queues,
publish_inbound=loop.bus.publish_inbound,
session_key=session_key,
)
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert session_key not in loop._cron_turns.deferred_queues
@@ -1097,7 +1102,11 @@ async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
assert await publish_next_deferred_turn(
deferred_queues=loop._local_trigger_turns.deferred_queues,
publish_inbound=loop.bus.publish_inbound,
session_key=session_key,
) is True
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert session_key not in loop._local_trigger_turns.deferred_queues
+2
View File
@@ -76,11 +76,13 @@ class TestHandleStop:
loop.subagents.close = close_subagents
loop._exec_session_manager.close_all = AsyncMock()
loop.tools.close = AsyncMock()
with patch("nanobot.agent.loop.agent_context.close_mcp", AsyncMock()):
await loop.close_mcp()
assert events == ["turn_cancelled", "resources_closed"]
assert task.cancelled()
loop.tools.close.assert_awaited_once()
@pytest.mark.asyncio
async def test_close_mcp_serializes_duplicate_cleanup(self):
+6 -58
View File
@@ -9,7 +9,6 @@ from types import SimpleNamespace
import pytest
from nanobot.agent.agent_plugins import discover_agent_plugin_skills
from nanobot.apps.cli.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
@@ -392,9 +391,6 @@ def test_install_dispatches_safe_pip_and_installs_skill(
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
legacy = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("legacy", encoding="utf-8")
payload = manager.install("gimp")
@@ -404,21 +400,9 @@ def test_install_dispatches_safe_pip_and_installs_skill(
assert "state_recorded" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
plugin = manager.workspace / "plugins" / "cli-app-gimp"
skill = plugin / "skills" / "cli-app-gimp" / "SKILL.md"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert json.loads((plugin / "plugin.json").read_text(encoding="utf-8")) == {
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "cli-app-gimp",
"version": "1.0.0",
"description": "Public duplicate entry",
}
assert "name: cli-app-gimp" in skill.read_text(encoding="utf-8")
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
assert [item.name for item in discover_agent_plugin_skills(manager.workspace)] == [
"cli-app-gimp"
]
assert not legacy.exists()
def test_run_argv_logs_command_exit_and_output(
@@ -503,14 +487,7 @@ def test_install_records_available_cli_without_reinstalling(
assert "entry_point_available" in payload["last_action"]["verification"]
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["feishu"]["entry_point_path"] == str(resolved)
skill = (
manager.workspace
/ "plugins"
/ "cli-app-feishu"
/ "skills"
/ "cli-app-feishu"
/ "SKILL.md"
)
skill = manager.workspace / "skills" / "cli-app-feishu" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="feishu"' in skill.read_text(encoding="utf-8")
@@ -727,8 +704,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
plugin_dir = manager.workspace / "plugins" / "cli-app-gimp"
skill_dir = plugin_dir / "skills" / "cli-app-gimp"
skill_dir = manager.workspace / "skills" / "cli-app-gimp"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("# GIMP\n", encoding="utf-8")
monkeypatch.setattr(
@@ -741,7 +717,7 @@ def test_uninstall_removes_installed_state_and_generated_skill(
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not plugin_dir.exists()
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
@@ -869,47 +845,19 @@ def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "plugins/cli-app-gimp/skills/cli-app-gimp/SKILL.md",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_legacy_underscored_skill_remains_visible_and_removable(tmp_path: Path) -> None:
manager = _manager(tmp_path)
legacy = manager.workspace / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text(
"---\nname: cli-app-unimol_tools\ndescription: Legacy Uni-Mol app.\n---\n",
encoding="utf-8",
)
manager._save_installed(
{"unimol_tools": {"entry_point": "cli-anything-unimol-tools", "source": "harness"}}
)
app = {
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
"install_cmd": "pip install cli-anything-unimol-tools",
}
assert manager._app_payload(app, manager._load_installed())["skill_installed"] is True
assert manager.mentioned_installed_apps("use @unimol_tools")[0]["skill"] == (
"skills/cli-app-unimol_tools/SKILL.md"
)
manager.remove_skill("unimol_tools")
assert not legacy.exists()
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
+9 -33
View File
@@ -1,9 +1,7 @@
"""Tests for CLI Apps loop helpers."""
from types import SimpleNamespace
from nanobot.apps.cli.service import CliAppManager
from nanobot.apps.cli.utils import runtime_lines, session_extra
from nanobot.apps.cli.utils import runtime_lines_for_request, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
@@ -30,8 +28,9 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
}
)
lines = runtime_lines(
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
lines = runtime_lines_for_request(
"please use @zoom tonight; ignore @krita?",
{},
tmp_path,
)
@@ -39,21 +38,19 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
lines = runtime_lines(
SimpleNamespace(
content="please use @zoom tonight",
metadata={
lines = runtime_lines_for_request(
"please use @zoom tonight",
{
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
),
tmp_path,
)
@@ -61,25 +58,4 @@ def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=plugins/cli-app-zoom/skills/cli-app-zoom/SKILL.md" in joined
def test_structured_cli_app_attachment_uses_existing_legacy_skill(tmp_path):
legacy = tmp_path / "skills" / "cli-app-unimol_tools" / "SKILL.md"
legacy.parent.mkdir(parents=True)
legacy.write_text("# Legacy Uni-Mol\n", encoding="utf-8")
lines = runtime_lines(
SimpleNamespace(
content="please use @unimol_tools",
metadata={
"cli_apps": [{
"name": "unimol_tools",
"entry_point": "cli-anything-unimol-tools",
}],
},
),
tmp_path,
)
assert "skill=skills/cli-app-unimol_tools/SKILL.md" in "\n".join(lines)
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
@@ -0,0 +1,50 @@
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
def test_chat_completions_moves_tool_images_after_parallel_results():
image = {
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
"_meta": {"path": "screen.png"},
}
messages = [
{"role": "assistant", "content": None, "tool_calls": [{"id": "a"}, {"id": "b"}]},
{
"role": "tool",
"tool_call_id": "a",
"content": [image, {"type": "text", "text": "clicked"}],
},
{"role": "tool", "tool_call_id": "b", "content": "other result"},
{"role": "assistant", "content": "done"},
]
result = OpenAICompatProvider._move_tool_images_to_user(messages)
assert result[1]["content"] == "clicked"
assert result[2] == messages[2]
assert result[3]["role"] == "user"
assert result[3]["content"][0] == {
"type": "image_url",
"image_url": {"url": "data:image/png;base64,AAAA"},
}
assert result[4] == messages[3]
def test_chat_completions_merges_tool_images_into_following_user_message():
messages = [
{
"role": "tool",
"tool_call_id": "a",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
],
},
{"role": "user", "content": "continue"},
]
result = OpenAICompatProvider._move_tool_images_to_user(messages)
assert len(result) == 2
assert result[0]["content"] == "(image returned)"
assert result[1]["role"] == "user"
assert result[1]["content"][-1] == {"type": "text", "text": "continue"}
+14
View File
@@ -73,3 +73,17 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
assert manager.flush_all() == 2
assert set(saved) == {("test:active", True), ("test:other", True)}
def test_transient_session_never_reaches_storage(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create_transient("websocket:temporary-test")
session.add_message("user", "secret")
manager.save(session, fsync=True)
assert manager.get_cached(session.key) is session
assert manager.read_session_file(session.key) is None
assert list(manager.sessions_dir.glob("*.jsonl")) == []
manager.invalidate(session.key)
assert manager.get_cached(session.key) is None
-26
View File
@@ -6,7 +6,6 @@ from zipfile import ZipFile
import pytest
from nanobot.utils.document import (
SUPPORTED_EXTENSIONS,
PdfSafetyError,
_is_text_extension,
extract_pdf_pages,
@@ -14,31 +13,6 @@ from nanobot.utils.document import (
)
class TestSupportedExtensions:
"""Test the SUPPORTED_EXTENSIONS constant."""
def test_supported_extensions_include_common_formats(self):
"""Test that common document formats are included."""
# Document formats
assert ".pdf" in SUPPORTED_EXTENSIONS
assert ".docx" in SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
assert ".pptx" in SUPPORTED_EXTENSIONS
# Text formats
assert ".txt" in SUPPORTED_EXTENSIONS
assert ".md" in SUPPORTED_EXTENSIONS
assert ".csv" in SUPPORTED_EXTENSIONS
assert ".json" in SUPPORTED_EXTENSIONS
assert ".yaml" in SUPPORTED_EXTENSIONS
assert ".yml" in SUPPORTED_EXTENSIONS
# Image formats
assert ".png" in SUPPORTED_EXTENSIONS
assert ".jpg" in SUPPORTED_EXTENSIONS
assert ".jpeg" in SUPPORTED_EXTENSIONS
class TestExtractText:
"""Test the extract_text function."""
+316
View File
@@ -0,0 +1,316 @@
"""Tests for DOM-based browser control."""
from __future__ import annotations
import asyncio
import io
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.tools.browser_tool import BrowserTool, BrowserToolConfig
from nanobot.agent.tools.computer_use_backends import browser_playwright
from nanobot.agent.tools.computer_use_backends.browser_playwright import BrowserBackend
from nanobot.agent.tools.context import RequestContext, request_context
class _FakeDomBackend:
environment = "browser"
def __init__(self):
self.calls: list[tuple] = []
self.elements = [
{"ref": 1, "tag": "button", "role": "", "type": "", "name": "Submit", "href": ""},
{"ref": 2, "tag": "input", "role": "", "type": "text", "name": "your name", "href": ""},
]
async def navigate(self, url):
self.calls.append(("navigate", url))
async def dom_snapshot(self, max_elements=200):
return self.elements
async def click_ref(self, ref):
self.calls.append(("click", ref))
async def fill_ref(self, ref, text, submit=False):
self.calls.append(("fill", ref, text, submit))
async def select_ref(self, ref, value):
self.calls.append(("select", ref, value))
async def scroll_page(self, direction, amount):
self.calls.append(("scroll", direction, amount))
async def key(self, combo):
self.calls.append(("key", combo))
async def go_back(self):
self.calls.append(("back",))
async def read_text(self, max_chars=4000):
return "the number is 42"
async def current_url(self):
return "http://test.local/page"
async def screenshot(self):
from PIL import Image
buf = io.BytesIO()
Image.new("RGB", (1280, 800), (0, 0, 0)).save(buf, format="PNG")
return buf.getvalue()
async def close(self):
self.calls.append(("close",))
def _tool(**kw):
fb = _FakeDomBackend()
return BrowserTool(BrowserToolConfig(**kw), backend_impl=fb), fb
def _route(url: str, *, navigation: bool):
return SimpleNamespace(
request=SimpleNamespace(
url=url,
is_navigation_request=MagicMock(return_value=navigation),
),
abort=AsyncMock(),
continue_=AsyncMock(),
)
class TestConfigAndMetadata:
def test_defaults_off(self):
cfg = BrowserToolConfig()
assert cfg.enable is False
assert cfg.headless is True
assert cfg.include_screenshot is False
assert cfg.max_elements == 200
assert cfg.max_sessions == 8
def test_enabled_reads_config(self):
ctx = MagicMock()
ctx.config.browser.enable = True
assert BrowserTool.enabled(ctx) is True
ctx.config.browser.enable = False
assert BrowserTool.enabled(ctx) is False
def test_create_from_ctx(self):
ctx = MagicMock()
ctx.config.browser = BrowserToolConfig(enable=True, allowed_domains=["example.com"])
tool = BrowserTool.create(ctx)
assert isinstance(tool, BrowserTool)
assert tool.config.allowed_domains == ["example.com"]
def test_metadata(self):
tool, _ = _tool()
assert tool.name == "browser"
assert tool.exclusive is True
assert tool.read_only is False
assert "subagent" not in tool._scopes
def test_schema_actions(self):
tool, _ = _tool()
enum = tool.parameters["properties"]["action"]["enum"]
for a in ("navigate", "snapshot", "click", "type", "read_text"):
assert a in enum
class TestDispatch:
@pytest.mark.asyncio
async def test_navigate_returns_snapshot(self):
tool, fb = _tool()
result = await tool.execute(action="navigate", url="https://example.com")
assert ("navigate", "https://example.com") in fb.calls
assert isinstance(result, str)
assert "Navigated to https://example.com" in result
# snapshot of interactive elements is appended
assert '[1] button "Submit"' in result
assert '[2] input[text] "your name"' in result
@pytest.mark.parametrize(
("action", "kwargs", "expected"),
[
("click", {"ref": 1}, ("click", 1)),
("type", {"ref": 2, "text": "Ada", "submit": True}, ("fill", 2, "Ada", True)),
("select", {"ref": 2, "value": "opt1"}, ("select", 2, "opt1")),
],
)
@pytest.mark.asyncio
async def test_element_actions(self, action, kwargs, expected):
tool, fb = _tool()
result = await tool.execute(action=action, **kwargs)
assert expected in fb.calls
assert "Interactive elements" in result
@pytest.mark.asyncio
async def test_scroll_and_key_and_back(self):
tool, fb = _tool()
await tool.execute(action="scroll", scroll_direction="down", scroll_amount=4)
await tool.execute(action="key", text="Enter")
await tool.execute(action="back")
assert ("scroll", "down", 4) in fb.calls
assert ("key", "Enter") in fb.calls
assert ("back",) in fb.calls
@pytest.mark.asyncio
async def test_read_text_returns_text_no_snapshot(self):
tool, _ = _tool()
result = await tool.execute(action="read_text")
assert isinstance(result, str)
assert "the number is 42" in result
assert "Interactive elements" not in result
@pytest.mark.asyncio
async def test_include_screenshot_returns_blocks(self):
tool, _ = _tool(include_screenshot=True)
result = await tool.execute(action="click", ref=1)
assert isinstance(result, list)
imgs = [b for b in result if b.get("type") == "image_url"]
texts = [b for b in result if b.get("type") == "text"]
assert imgs and texts
assert "Clicked element [1]" in texts[-1]["text"]
@pytest.mark.asyncio
async def test_calls_are_serialized_across_sessions(self):
class SlowBackend(_FakeDomBackend):
active = 0
max_active = 0
async def dom_snapshot(self, max_elements=200):
self.active += 1
self.max_active = max(self.max_active, self.active)
await asyncio.sleep(0.01)
self.active -= 1
return await super().dom_snapshot(max_elements)
backend = SlowBackend()
tool = BrowserTool(backend_impl=backend)
async def snapshot(session: str):
with request_context(
RequestContext(channel="test", chat_id=session, session_key=session)
):
return await tool.execute(action="snapshot")
await asyncio.gather(snapshot("a"), snapshot("b"))
assert backend.max_active == 1
class TestErrorsAndPolicy:
@pytest.mark.parametrize(
("kwargs", "error"),
[
({"action": "teleport"}, "unknown action"),
({"action": "click"}, "requires an element 'ref'"),
],
)
@pytest.mark.asyncio
async def test_tool_errors_are_returned_to_model(self, kwargs, error):
tool, _ = _tool()
result = await tool.execute(**kwargs)
assert isinstance(result, str) and error in result
assert result.is_error is True
@pytest.mark.asyncio
async def test_backend_blocks_disallowed_navigation(self):
backend = BrowserBackend(allowed_domains=["example.com"])
page = SimpleNamespace(goto=AsyncMock())
backend._page = page
with pytest.raises(ValueError, match="allowed_domains"):
await backend.navigate("https://evil.test/")
page.goto.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_allows_subdomain_navigation(self, monkeypatch: pytest.MonkeyPatch):
check = MagicMock(return_value=(True, ""))
monkeypatch.setattr(browser_playwright, "validate_url_target", check)
backend = BrowserBackend(allowed_domains=["example.com"])
page = SimpleNamespace(goto=AsyncMock())
backend._page = page
await backend.navigate("https://app.example.com/x")
page.goto.assert_awaited_once_with("https://app.example.com/x")
check.assert_called_once()
@pytest.mark.parametrize(
"url",
[
"file:///etc/passwd",
"http://127.0.0.1/",
"http://169.254.169.254/latest/meta-data/",
"ws://localhost/socket",
],
)
@pytest.mark.asyncio
async def test_browser_network_policy_blocks_local_targets(self, url: str):
backend = BrowserBackend()
with pytest.raises(ValueError, match="blocked"):
await backend.navigate(url)
@pytest.mark.asyncio
async def test_backend_intercepts_blocked_navigation(self):
backend = BrowserBackend(allowed_domains=["example.com"])
route = _route("https://evil.test/", navigation=True)
await backend._route_request(route)
route.abort.assert_awaited_once_with("blockedbyclient")
route.continue_.assert_not_awaited()
assert "allowed_domains" in (backend.pop_blocked_navigation() or "")
@pytest.mark.asyncio
async def test_backend_intercepts_private_subresource(self):
backend = BrowserBackend()
route = _route(
"http://169.254.169.254/latest/meta-data/",
navigation=False,
)
await backend._route_request(route)
route.abort.assert_awaited_once_with("blockedbyclient")
assert backend.pop_blocked_navigation() is None
@pytest.mark.asyncio
async def test_backend_does_not_apply_navigation_allowlist_to_subresources(
self, monkeypatch: pytest.MonkeyPatch
):
monkeypatch.setattr(
browser_playwright,
"validate_url_target",
MagicMock(return_value=(True, "")),
)
backend = BrowserBackend(allowed_domains=["example.com"])
route = _route("https://cdn.other.test/app.js", navigation=False)
await backend._route_request(route)
route.continue_.assert_awaited_once()
route.abort.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_intercepts_private_websocket(self):
backend = BrowserBackend()
web_socket = SimpleNamespace(
url="ws://127.0.0.1/socket",
close=AsyncMock(),
connect_to_server=AsyncMock(),
)
await backend._route_web_socket(web_socket)
web_socket.close.assert_awaited_once()
web_socket.connect_to_server.assert_not_awaited()
@pytest.mark.asyncio
async def test_backend_rejects_file_start_url_before_launch(self):
backend = BrowserBackend(start_url="file:///etc/passwd")
with pytest.raises(ValueError, match="start_url is blocked"):
await backend.dimensions()
+330
View File
@@ -0,0 +1,330 @@
"""Tests for screenshot-based computer control."""
from __future__ import annotations
import asyncio
import io
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from nanobot.agent.tools.computer_use import ComputerUseTool, ComputerUseToolConfig
from nanobot.agent.tools.computer_use_backends.base import ComputerBackend, SessionBackendPool
from nanobot.agent.tools.computer_use_backends.desktop_pyautogui import DesktopBackend
from nanobot.agent.tools.context import RequestContext, request_context
from nanobot.config.schema import ToolsConfig
class _FakeBackend(ComputerBackend):
"""Records actuation calls and serves a solid-colour PNG of a fixed size."""
environment = "desktop"
def __init__(self, width: int = 2560, height: int = 1600):
self.calls: list[tuple] = []
self._w, self._h = width, height
self.closed = False
async def dimensions(self) -> tuple[int, int]:
return (self._w, self._h)
async def screenshot(self) -> bytes:
from PIL import Image
img = Image.new("RGB", (self._w, self._h), (10, 20, 30))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
async def click(self, x, y, button="left", count=1):
self.calls.append(("click", x, y, button, count))
async def move(self, x, y):
self.calls.append(("move", x, y))
async def drag(self, x, y):
self.calls.append(("drag", x, y))
async def scroll(self, x, y, direction, amount):
self.calls.append(("scroll", x, y, direction, amount))
async def type_text(self, text):
self.calls.append(("type", text))
async def key(self, combo):
self.calls.append(("key", combo))
async def close(self):
self.closed = True
# navigate() inherited -> raises NotImplementedError (desktop has no navigate)
def _split(result):
assert isinstance(result, list), f"expected content blocks, got {result!r}"
images = [b for b in result if isinstance(b, dict) and b.get("type") == "image_url"]
texts = [b for b in result if isinstance(b, dict) and b.get("type") == "text"]
return images, texts
def _tool(**kw):
fb = _FakeBackend(width=kw.pop("w", 2560), height=kw.pop("h", 1600))
config = ComputerUseToolConfig(target_width=1280, target_height=800, **kw)
tool = ComputerUseTool(config, backend_impl=fb)
return tool, fb
# --------------------------- config + metadata ---------------------------
class TestConfigAndMetadata:
def test_defaults_off(self):
cfg = ComputerUseToolConfig()
assert cfg.enable is False
assert cfg.backend == "desktop"
assert (cfg.target_width, cfg.target_height) == (1280, 800)
assert cfg.max_sessions == 8
assert "require_approval" not in type(cfg).model_fields
def test_tools_config_accepts_camel_case(self):
cfg = ToolsConfig.model_validate({
"browser": {"enable": True, "maxSessions": 4},
"computerUse": {"enable": True, "backend": "browser", "maxSessions": 6},
})
assert cfg.browser.enable is True
assert cfg.browser.max_sessions == 4
assert cfg.computer_use.enable is True
assert cfg.computer_use.backend == "browser"
assert cfg.computer_use.max_sessions == 6
dumped = cfg.model_dump(by_alias=True)
assert "computerUse" in dumped
assert dumped["computerUse"]["maxSessions"] == 6
def test_enabled_reads_config(self):
ctx = MagicMock()
ctx.config.computer_use.enable = True
assert ComputerUseTool.enabled(ctx) is True
ctx.config.computer_use.enable = False
assert ComputerUseTool.enabled(ctx) is False
def test_create_from_ctx(self):
ctx = MagicMock()
ctx.config.computer_use = ComputerUseToolConfig(
enable=True, backend="browser", target_width=1024, target_height=768
)
tool = ComputerUseTool.create(ctx)
assert isinstance(tool, ComputerUseTool)
assert tool.config.backend == "browser"
assert (tool.config.target_width, tool.config.target_height) == (1024, 768)
def test_tool_metadata(self):
tool, _ = _tool()
assert tool.name == "computer_use"
assert tool.exclusive is True
assert tool.read_only is False
assert tool.concurrency_safe is False
# not exposed to subagents
assert "subagent" not in tool._scopes
def test_schema_has_action_enum(self):
tool, _ = _tool()
action = tool.parameters["properties"]["action"]
assert "screenshot" in action["enum"]
assert "left_click" in action["enum"]
assert tool.parameters["required"] == ["action"]
# --------------------------- execute dispatch ---------------------------
class TestExecute:
@pytest.mark.asyncio
async def test_screenshot_returns_image_blocks(self):
tool, fb = _tool()
result = await tool.execute(action="screenshot")
images, texts = _split(result)
assert len(images) == 1
assert images[0]["image_url"]["url"].startswith("data:image/png;base64,")
assert "1280x800" in texts[-1]["text"]
assert fb.calls == [] # screenshot performs no actuation
@pytest.mark.asyncio
async def test_left_click_scales_coordinates(self):
tool, fb = _tool() # real 2560x1600 -> target 1280x800 (2x)
result = await tool.execute(action="left_click", x=100, y=50)
assert fb.calls == [("click", 200, 100, "left", 1)]
_, texts = _split(result)
assert "left_click at (200, 100)" in texts[-1]["text"]
@pytest.mark.asyncio
async def test_click_clamps_coordinates_to_screen(self):
tool, fb = _tool()
await tool.execute(action="left_click", x=5000, y=-10)
assert fb.calls == [("click", 2559, 0, "left", 1)]
@pytest.mark.parametrize(
("action", "kwargs", "expected"),
[
("double_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 2)),
("triple_click", {"x": 10, "y": 10}, ("click", 20, 20, "left", 3)),
("right_click", {"x": 5, "y": 5}, ("click", 10, 10, "right", 1)),
("middle_click", {"x": 5, "y": 5}, ("click", 10, 10, "middle", 1)),
(
"scroll",
{"x": 100, "y": 100, "scroll_direction": "down", "scroll_amount": 5},
("scroll", 200, 200, "down", 5),
),
("type", {"text": "hello"}, ("type", "hello")),
("key", {"text": "ctrl+s"}, ("key", "ctrl+s")),
("mouse_move", {"x": 10, "y": 10}, ("move", 20, 20)),
("left_click_drag", {"x": 20, "y": 30}, ("drag", 40, 60)),
],
)
@pytest.mark.asyncio
async def test_actions_dispatch_to_backend(self, action, kwargs, expected):
tool, fb = _tool()
await tool.execute(action=action, **kwargs)
assert fb.calls == [expected]
@pytest.mark.asyncio
async def test_wait(self):
tool, fb = _tool()
result = await tool.execute(action="wait", duration=0.0)
_, texts = _split(result)
assert "Waited" in texts[-1]["text"]
@pytest.mark.parametrize(
("kwargs", "error"),
[
({"action": "frobnicate"}, "unknown action"),
({"action": "left_click"}, "requires"),
({"action": "navigate", "url": "https://example.com"}, "Error"),
],
)
@pytest.mark.asyncio
async def test_errors_are_returned_to_model(self, kwargs, error):
tool, _ = _tool()
result = await tool.execute(**kwargs)
assert isinstance(result, str) and error in result
assert result.is_error is True
@pytest.mark.asyncio
async def test_backend_pool_isolates_sessions_and_closes_all():
created: list[_FakeBackend] = []
finalized: list[bool] = []
def factory():
backend = _FakeBackend()
created.append(backend)
return backend
async def finalize():
finalized.append(all(backend.closed for backend in created))
pool = SessionBackendPool(factory, finalizer=finalize)
with request_context(RequestContext(channel="test", chat_id="a", session_key="test:a")):
first = await pool.get()
assert await pool.get() is first
with request_context(RequestContext(channel="test", chat_id="b", session_key="test:b")):
second = await pool.get()
assert first is not second
await pool.close()
assert len(created) == 2
assert all(backend.closed for backend in created)
assert finalized == [True]
await pool.close()
assert finalized == [True]
@pytest.mark.asyncio
async def test_backend_pool_evicts_least_recently_used_session():
created: list[_FakeBackend] = []
def factory():
backend = _FakeBackend()
created.append(backend)
return backend
pool = SessionBackendPool(factory, max_backends=2)
contexts = [
RequestContext(channel="test", chat_id=key, session_key=f"test:{key}")
for key in ("a", "b", "c")
]
with request_context(contexts[0]):
first = await pool.get()
with request_context(contexts[1]):
second = await pool.get()
with request_context(contexts[0]):
assert await pool.get() is first
with request_context(contexts[2]):
await pool.get()
assert first.closed is False
assert second.closed is True
await pool.close()
@pytest.mark.asyncio
async def test_desktop_tool_serializes_calls_across_sessions():
class SlowBackend(_FakeBackend):
active = 0
max_active = 0
async def dimensions(self):
self.active += 1
self.max_active = max(self.max_active, self.active)
await asyncio.sleep(0.01)
self.active -= 1
return await super().dimensions()
backend = SlowBackend(width=1280, height=800)
tool = ComputerUseTool(backend_impl=backend)
async def screenshot(session: str):
with request_context(RequestContext(channel="test", chat_id=session, session_key=session)):
return await tool.execute(action="screenshot")
await asyncio.gather(screenshot("a"), screenshot("b"))
assert backend.max_active == 1
@pytest.mark.asyncio
async def test_desktop_backend_uses_safe_pyautogui_calls():
pg = MagicMock()
pg.easeInOutQuad = object()
backend = DesktopBackend()
backend._pg = pg
await backend.drag(10, 20)
await backend.scroll(10, 20, "down", 3)
assert pg.dragTo.call_args.kwargs == {
"duration": 0.3,
"tween": pg.easeInOutQuad,
"button": "left",
}
pg.scroll.assert_called_once_with(-3)
@pytest.mark.asyncio
async def test_desktop_backend_rejects_unicode_instead_of_typing_incorrect_keys():
pg = MagicMock()
backend = DesktopBackend()
backend._pg = pg
with pytest.raises(ValueError, match="ASCII"):
await backend.type_text("你好")
pg.typewrite.assert_not_called()
def test_desktop_backend_preserves_pyautogui_failsafe(monkeypatch):
pg = SimpleNamespace(FAILSAFE=True)
monkeypatch.setitem(sys.modules, "pyautogui", pg)
backend = DesktopBackend()
assert backend._ensure() is pg
assert pg.FAILSAFE is True
+1 -36
View File
@@ -13,7 +13,7 @@ import os
import pytest
from nanobot.agent.tools import file_state
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
@pytest.fixture(autouse=True)
@@ -68,41 +68,6 @@ class TestDeleteLineCleanup:
# ---------------------------------------------------------------------------
class TestSmartQuoteNormalization:
"""_find_match should handle curly ↔ straight quote fallback."""
def test_curly_double_quotes_match_straight(self):
content = 'She said \u201chello\u201d to him'
old_text = 'She said "hello" to him'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
# Returned match should be the ORIGINAL content with curly quotes
assert "\u201c" in match
def test_curly_single_quotes_match_straight(self):
content = "it\u2019s a test"
old_text = "it's a test"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
assert "\u2019" in match
def test_straight_matches_curly_in_old_text(self):
content = 'x = "hello"'
old_text = 'x = \u201chello\u201d'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
def test_exact_match_still_preferred_over_quote_normalization(self):
content = 'x = "hello"'
old_text = 'x = "hello"'
match, count = _find_match(content, old_text)
assert match == old_text
assert count == 1
class TestQuoteStylePreservation:
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
-47
View File
@@ -7,7 +7,6 @@ from nanobot.agent.tools.filesystem import (
ListDirTool,
ReadFileTool,
WriteFileTool,
_find_match,
)
# ---------------------------------------------------------------------------
@@ -116,52 +115,6 @@ class TestReadFileTool:
assert "Maximum is 100 MiB" in result
# ---------------------------------------------------------------------------
# _find_match (unit tests for the helper)
# ---------------------------------------------------------------------------
class TestFindMatch:
def test_exact_match(self):
match, count = _find_match("hello world", "world")
assert match == "world"
assert count == 1
def test_exact_no_match(self):
match, count = _find_match("hello world", "xyz")
assert match is None
assert count == 0
def test_crlf_normalisation(self):
# Caller normalises CRLF before calling _find_match, so test with
# pre-normalised content to verify exact match still works.
content = "line1\nline2\nline3"
old_text = "line1\nline2\nline3"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
def test_line_trim_fallback(self):
content = " def foo():\n pass\n"
old_text = "def foo():\n pass"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
# The returned match should be the *original* indented text
assert " def foo():" in match
def test_line_trim_multiple_candidates(self):
content = " a\n b\n a\n b\n"
old_text = "a\nb"
match, count = _find_match(content, old_text)
assert count == 2
def test_empty_old_text(self):
match, count = _find_match("hello", "")
# Empty string is always "in" any string via exact match
assert match == ""
# ---------------------------------------------------------------------------
# EditFileTool
# ---------------------------------------------------------------------------
+30
View File
@@ -28,6 +28,18 @@ class _FakeTool(Tool):
async def execute(self, **kwargs: Any) -> Any:
return kwargs
class _ClosableTool(_FakeTool):
def __init__(self, name: str, *, error: BaseException | None = None):
super().__init__(name)
self.closed = False
self.error = error
async def close(self) -> None:
self.closed = True
if self.error is not None:
raise self.error
def _tool_names(definitions: list[dict[str, Any]]) -> list[str]:
names: list[str] = []
for definition in definitions:
@@ -58,6 +70,24 @@ def test_get_definitions_orders_builtins_then_mcp_tools() -> None:
]
async def test_close_attempts_every_registered_tool() -> None:
registry = ToolRegistry()
broken = _ClosableTool("broken", error=RuntimeError("close failed"))
healthy = _ClosableTool("healthy")
registry.register(broken)
registry.register(healthy)
try:
await registry.close()
except RuntimeError as exc:
assert str(exc) == "close failed"
else:
raise AssertionError("expected close failure")
assert broken.closed is True
assert healthy.closed is True
def test_prepare_call_rejects_near_miss_tool_name_with_suggestion() -> None:
registry = ToolRegistry()
registry.register(_FakeTool("read_file"))
+11 -16
View File
@@ -9,7 +9,6 @@ from nanobot.utils.file_edit_events import (
build_file_edit_start_event,
build_unified_diff_payload,
line_diff_stats,
prepare_file_edit_tracker,
prepare_file_edit_trackers,
read_file_snapshot,
)
@@ -44,15 +43,14 @@ def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Pat
target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8")
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-write",
tool_name="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params=params,
)
assert tracker is not None
[tracker] = trackers
start = build_file_edit_start_event(tracker)
assert start == {
"version": 1,
@@ -103,15 +101,14 @@ def test_unified_diff_payload_truncates_large_diffs() -> None:
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "data.bin"
target.write_bytes(b"\x00\x01before")
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-bin",
tool_name="edit_file",
tool=_edit_tool(tmp_path),
workspace=tmp_path,
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
)
assert tracker is not None
[tracker] = trackers
assert not read_file_snapshot(target).countable
target.write_bytes(b"\x00\x01after")
event = build_file_edit_end_event(tracker)
@@ -123,15 +120,14 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
def test_binary_before_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "data.bin"
target.write_bytes(b"\x00\x01before")
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-bin",
tool_name="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params={"path": "data.bin", "content": "after\n"},
)
assert tracker is not None
[tracker] = trackers
target.write_text("after\n", encoding="utf-8")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
@@ -215,15 +211,14 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "large.txt"
params = {"path": "large.txt", "content": "x"}
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-large",
tool_name="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params=params,
)
assert tracker is not None
[tracker] = trackers
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
@@ -232,11 +227,11 @@ def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert "diff" not in event
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
assert prepare_file_edit_trackers(
call_id="call-exec",
tool_name="exec",
tool=None,
workspace=tmp_path,
params={"path": "created-by-shell.txt"},
) is None
) == []
+2 -86
View File
@@ -1,13 +1,12 @@
"""Tests for GitStore — line_ages() and core git operations."""
"""Tests for GitStore core operations."""
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import GitStore, GitStoreError
from nanobot.utils.gitstore import GitStore
@pytest.fixture
@@ -18,89 +17,6 @@ def git(tmp_path):
return g
class TestLineAges:
def test_returns_empty_when_not_initialized(self, tmp_path):
"""line_ages should return [] if the git repo is not initialized."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
assert git.line_ages("MEMORY.md") == []
def test_returns_empty_for_missing_file(self, git):
"""line_ages should return [] for a file that doesn't exist."""
assert git.line_ages("SOUL.md") == []
def test_returns_empty_for_empty_file(self, git, tmp_path):
"""line_ages should return [] for an empty tracked file."""
(tmp_path / "SOUL.md").write_text("", encoding="utf-8")
git.auto_commit("empty soul")
assert git.line_ages("SOUL.md") == []
def test_one_age_per_line(self, git, tmp_path):
"""line_ages should return one entry per line in the file."""
content = "# Memory\n\n## Section A\n- item 1\n"
(tmp_path / "MEMORY.md").write_text(content, encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert len(ages) == len(content.splitlines())
def test_fresh_lines_have_age_zero(self, git, tmp_path):
"""Lines committed today should have age_days=0."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert all(a.age_days == 0 for a in ages)
def test_age_differentiates_across_days(self, git, tmp_path):
"""Lines committed today should show correct age when 'now' is mocked forward."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
future_now = datetime.now(tz=timezone.utc) + timedelta(days=30)
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = future_now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
assert len(ages) == 2
assert all(a.age_days == 30 for a in ages)
def test_annotate_failure_is_explicit(self, git, tmp_path):
(tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8")
git.auto_commit("initial")
with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")):
with pytest.raises(GitStoreError, match="annotation failed"):
git.line_ages("MEMORY.md")
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
"""Only modified lines should reflect the new commit's timestamp."""
now = datetime(2026, 5, 1, tzinfo=timezone.utc)
old = now - timedelta(days=30)
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8"
)
with patch("dulwich.worktree.time.time", return_value=old.timestamp()):
git.auto_commit("commit1")
# Only modify section A
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8"
)
with patch("dulwich.worktree.time.time", return_value=now.timestamp()):
git.auto_commit("commit2")
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines()
assert len(ages) == len(lines)
age_by_line = {line: age.age_days for line, age in zip(lines, ages, strict=True)}
assert age_by_line["- new"] == 0
assert age_by_line["- keep"] == 30
class TestSummarizeWorkingTree:
"""Ground-truth diff summary used to keep Dream audit records honest."""
-8
View File
@@ -1,14 +1,11 @@
from pathlib import Path
from zoneinfo import ZoneInfoNotFoundError
import pytest
import tiktoken
from nanobot.utils import helpers
from nanobot.utils.helpers import (
_write_text_atomic,
content_with_media_breadcrumbs,
current_time_str,
split_message,
truncate_text_to_tokens,
)
@@ -51,11 +48,6 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text():
assert truncate_text_to_tokens(text, 0) == text
def test_current_time_str_rejects_unknown_timezone():
with pytest.raises(ZoneInfoNotFoundError):
current_time_str("Not/AZone")
def test_content_with_media_breadcrumbs_preserves_valid_paths():
assert content_with_media_breadcrumbs(
"user",
+30
View File
@@ -29,6 +29,36 @@ def test_estimate_prompt_tokens_chain_falls_back_without_provider_counter() -> N
assert source == "tiktoken"
def test_image_blocks_have_bounded_token_cost() -> None:
text = [{"role": "tool", "content": [{"type": "text", "text": "screen"}]}]
small_image = [{
"role": "tool",
"content": [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,A"}},
{"type": "text", "text": "screen"},
],
}]
large_image = [{
"role": "tool",
"content": [
{
"type": "image_url",
"image_url": {"url": "data:image/png;base64," + "A" * 100_000},
},
{"type": "text", "text": "screen"},
],
}]
text_tokens = estimate_prompt_tokens(text)
small_tokens = estimate_prompt_tokens(small_image)
large_tokens = estimate_prompt_tokens(large_image)
assert small_tokens >= text_tokens + 2_000
assert large_tokens == small_tokens
assert estimate_message_tokens(large_image[0]) >= text_tokens + 2_000
assert estimate_message_tokens({"role": "user", "content": small_image[0]["content"][:1]}) > 2_000
def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -> None:
tokens, source = estimate_prompt_tokens_chain(
_BrokenCounterProvider(),
+48
View File
@@ -29,6 +29,7 @@ from nanobot.webui.settings_api import (
settings_usage_payload,
update_agent_settings,
update_api_settings,
update_computer_use_settings,
update_model_call_order,
update_model_configuration,
update_network_safety_settings,
@@ -1007,6 +1008,27 @@ def test_settings_payload_includes_network_safety_fields(
assert payload["advanced"]["ssrf_whitelist_count"] == 1
def test_settings_payload_includes_computer_use_tools(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.browser.enable = True
config.tools.computer_use.enable = True
config.tools.computer_use.backend = "browser"
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = settings_payload()
assert payload["computer_use"] == {
"browser_enabled": True,
"enabled": True,
"backend": "browser",
}
def test_settings_payload_includes_exec_path_flags(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
@@ -1374,6 +1396,32 @@ def test_update_network_safety_settings_writes_local_service_flag(
assert payload["requires_restart"] is True
def test_update_computer_use_settings_writes_only_requested_switches(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
browser_payload = update_computer_use_settings({"browser_enabled": ["true"]})
saved = load_config(config_path)
assert saved.tools.browser.enable is True
assert saved.tools.computer_use.enable is False
assert browser_payload["requires_restart"] is True
computer_payload = update_computer_use_settings({"computerEnabled": ["true"]})
saved = load_config(config_path)
assert saved.tools.browser.enable is True
assert saved.tools.computer_use.enable is True
assert computer_payload["computer_use"]["enabled"] is True
def test_update_computer_use_settings_requires_a_switch() -> None:
with pytest.raises(WebUISettingsError, match="browser_enabled or enabled"):
update_computer_use_settings({})
def test_update_network_safety_settings_accepts_legacy_restricted_default_access(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
+51
View File
@@ -138,3 +138,54 @@ async def test_model_preset_mutation_routes(
assert response.status_code == 200
assert json.loads(response.body)["routed"] == function_name
assert captured["query"] == expected_query
@pytest.mark.parametrize(
("query", "expected_query", "expected_sections"),
[
(
"browser_enabled=true",
{"browser_enabled": ["true"]},
["browser"],
),
(
"computerEnabled=true",
{"computerEnabled": ["true"]},
["runtime"],
),
(
"browserEnabled=true&enabled=true",
{"browserEnabled": ["true"], "enabled": ["true"]},
["browser", "runtime"],
),
],
)
@pytest.mark.asyncio
async def test_computer_use_update_route(
monkeypatch,
query: str,
expected_query: dict[str, list[str]],
expected_sections: list[str],
) -> None:
captured: dict[str, object] = {}
def update(query):
captured["query"] = query
return {"requires_restart": True}
monkeypatch.setattr("nanobot.webui.settings_routes.update_computer_use_settings", update)
request = SimpleNamespace(
path=f"/api/settings/computer-use/update?{query}",
headers=Headers(),
)
response = await _router().dispatch(
None,
request,
"/api/settings/computer-use/update",
)
assert response is not None
assert response.status_code == 200
assert captured["query"] == expected_query
assert json.loads(response.body)["restart_required_sections"] == expected_sections
-5
View File
@@ -9,7 +9,6 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
@@ -250,8 +249,6 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
@@ -1332,8 +1329,6 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
-1
View File
@@ -16,7 +16,6 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

+206 -15
View File
@@ -61,7 +61,11 @@ import {
createRuntimeHost,
toRuntimeSurface,
} from "@/lib/runtime";
import { projectNameFromPath } from "@/lib/workspace";
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
import {
createTemporaryChatSession,
deriveTemporaryChatTitle,
} from "@/lib/temporary-chat";
type BootState =
| { status: "loading" }
@@ -96,8 +100,8 @@ type ShellRoute = {
view: ShellView;
activeKey: string | null;
settingsSection: SettingsSectionKey;
temporary?: boolean;
};
const loadSettingsView = () => import("@/components/settings/SettingsView");
const SettingsView = lazy(async () => {
const module = await loadSettingsView();
@@ -227,6 +231,22 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path.startsWith("/temporary/")) {
const encoded = path.slice("/temporary/".length);
try {
const chatId = decodeURIComponent(encoded).trim();
return chatId
? {
view: "chat",
activeKey: `websocket:${chatId}`,
settingsSection: "overview",
temporary: true,
}
: defaultShellRoute();
} catch {
return defaultShellRoute();
}
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@@ -243,6 +263,10 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
if (route.temporary && route.activeKey?.startsWith("websocket:")) {
const chatId = route.activeKey.slice("websocket:".length);
return `#/temporary/${encodeURIComponent(chatId)}`;
}
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
@@ -961,6 +985,8 @@ function Shell({
initialRouteRef.current.activeKey,
);
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [temporarySessions, setTemporarySessions] = useState<Record<string, ChatSummary>>({});
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
@@ -1005,11 +1031,26 @@ function Shell({
const runningChatIdsRef = useRef<Set<string>>(new Set());
const activeChatIdRef = useRef<string | null>(null);
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
const effectiveRuntimeSurface =
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null;
const temporaryChatId = activeTemporarySession?.chatId ?? null;
const temporaryChatActive = view === "chat" && temporaryChatId !== null;
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
const temporarySessionList = useMemo(
() => Object.values(temporarySessions).sort((a, b) => (
Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "")
)),
[temporarySessions],
);
const temporaryChatIds = useMemo(
() => temporarySessionList.map((session) => session.chatId),
[temporarySessionList],
);
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
@@ -1036,6 +1077,21 @@ function Shell({
return () => window.removeEventListener("hashchange", applyRoute);
}, []);
useEffect(() => {
temporarySessionsRef.current = temporarySessions;
}, [temporarySessions]);
useEffect(() => {
if (view === "chat" && !activeKey) return;
setTemporaryChatEnabled(false);
}, [activeKey, view]);
useEffect(() => () => {
for (const session of Object.values(temporarySessionsRef.current)) {
client.discardTemporaryChat(session.chatId);
}
}, [client]);
useEffect(() => {
let cancelled = false;
fetchSettings(getToken())
@@ -1121,8 +1177,9 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
}, [sessions, activeKey, temporarySessions]);
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1137,6 +1194,11 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (temporaryChatRequested) {
return workspaces?.default_scope
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
: null;
}
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
@@ -1148,6 +1210,7 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
temporaryChatRequested,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1187,11 +1250,17 @@ function Shell({
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
pendingCreatedSessionKeyRef.current = null;
}
if (!activeKey || sessions.some((session) => session.key === activeKey)) return;
if (!activeKey) return;
const currentRoute = readShellRoute();
if (currentRoute.temporary) {
if (temporarySessions[activeKey]) return;
navigate(defaultShellRoute(), { replace: true });
return;
}
if (sessions.some((session) => session.key === activeKey)) return;
// WebKit can commit the route before useSessions' optimistic insert.
// Keep that just-created destination valid until the session list catches up.
if (pendingCreatedKey === activeKey) return;
const currentRoute = readShellRoute();
navigate(
currentRoute.view === "chat"
? defaultShellRoute()
@@ -1201,7 +1270,7 @@ function Shell({
},
{ replace: true },
);
}, [activeKey, loading, navigate, sessions]);
}, [activeKey, loading, navigate, sessions, temporarySessions]);
useEffect(() => {
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
@@ -1360,14 +1429,22 @@ function Shell({
const next = normalizeWorkspaceScope(scope);
setWorkspaceError(null);
if (activeChatId) {
if (!activeChatRunning) {
if (temporaryChatActive) {
setTemporarySessions((current) => {
if (!activeKey || !current[activeKey]) return current;
return {
...current,
[activeKey]: { ...current[activeKey], workspaceScope: next },
};
});
} else if (!activeChatRunning) {
client.setWorkspaceScope(activeChatId, next);
}
return;
}
setDraftWorkspaceScope(next);
},
[activeChatId, activeChatRunning, client],
[activeChatId, activeChatRunning, activeKey, client, temporaryChatActive],
);
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
@@ -1398,6 +1475,45 @@ function Shell({
}
}, [activeWorkspaceScope, createChat, navigate, t]);
const onCreateTemporaryChat = useCallback(
async (
workspaceScope?: WorkspaceScopePayload | null,
initialMessage?: string,
) => {
try {
const chatId = await client.newTemporaryChat();
const session = createTemporaryChatSession(chatId);
const restrictedScope = workspaceScope
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
: null;
const nextSession: ChatSummary = {
...session,
preview: initialMessage ?? "",
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
};
setTemporarySessions((current) => ({
...current,
[nextSession.key]: nextSession,
}));
setTemporaryChatEnabled(false);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: nextSession.key,
settingsSection: "overview",
temporary: true,
});
setMobileSidebarOpen(false);
return nextSession.chatId;
} catch (error) {
console.error("Failed to create temporary chat", error);
return null;
}
},
[client, navigate],
);
const onForkChat = useCallback(async (
sourceChatId: string,
beforeUserIndex: number,
@@ -1427,12 +1543,20 @@ function Shell({
const onNewChat = useCallback(() => {
navigate(defaultShellRoute());
setTemporaryChatEnabled(false);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
setMobileSidebarOpen(false);
}, [navigate]);
const onTemporaryChatEnabledChange = useCallback((enabled: boolean) => {
if (view !== "chat" || activeKey) return;
setTemporaryChatEnabled(enabled);
setDraftWorkspaceScope(null);
setWorkspaceError(null);
}, [activeKey, view]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1441,6 +1565,7 @@ function Shell({
onNewChat();
return;
}
setTemporaryChatEnabled(false);
navigate(defaultShellRoute());
setDraftWorkspaceScope(normalizeWorkspaceScope({
project_path: trimmed,
@@ -1456,7 +1581,9 @@ function Shell({
const onSelectChat = useCallback(
(key: string) => {
const selected = sessions.find((session) => session.key === key);
const selectedTemporary = temporarySessionsRef.current[key];
const selected = selectedTemporary
?? sessions.find((session) => session.key === key);
const selectedChatId = selected?.chatId;
if (selectedChatId) {
setUpdatedChatIds((current) => {
@@ -1472,12 +1599,38 @@ function Shell({
setDraftWorkspaceScope(null);
}
setWorkspaceError(null);
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
navigate({
view: "chat",
activeKey: key,
settingsSection: "overview",
...(selectedTemporary ? { temporary: true } : {}),
});
setMobileSidebarOpen(false);
},
[navigate, sessions],
);
const onCloseTemporaryChat = useCallback((key: string) => {
const session = temporarySessionsRef.current[key];
if (!session) return;
const remaining = temporarySessionList.filter((item) => item.key !== key);
const nextSessions = Object.fromEntries(remaining.map((item) => [item.key, item]));
temporarySessionsRef.current = nextSessions;
setTemporarySessions(nextSessions);
client.discardTemporaryChat(session.chatId);
if (activeKey === key) {
if (remaining.length === 0) setDraftWorkspaceScope(null);
setWorkspaceError(null);
navigate({
view: "chat",
activeKey: remaining[0]?.key ?? null,
settingsSection: "overview",
...(remaining[0] ? { temporary: true } : {}),
}, { replace: true });
}
setMobileSidebarOpen(false);
}, [activeKey, client, navigate, temporarySessionList]);
const onTogglePin = useCallback(
(key: string) => {
void updateSidebarState((current) => {
@@ -1760,6 +1913,11 @@ function Shell({
nextRunning.delete(chatId);
runningChatIdsRef.current = nextRunning;
setRunningChatIds(nextRunning);
if (
Object.values(temporarySessionsRef.current).some(
(session) => session.chatId === chatId,
)
) return;
setUpdatedChatIds((current) => {
const next = new Set(current);
if (activeChatIdRef.current === chatId) {
@@ -1772,6 +1930,24 @@ function Shell({
});
}, [client]);
useEffect(() => {
let wasOpen = client.status === "open";
return client.onStatus((status) => {
if (status === "open") {
wasOpen = true;
return;
}
if (!wasOpen) return;
wasOpen = false;
if (Object.keys(temporarySessionsRef.current).length === 0) return;
temporarySessionsRef.current = {};
setTemporarySessions({});
if (readShellRoute().temporary) {
navigate(defaultShellRoute(), { replace: true });
}
});
}, [client, navigate]);
useEffect(() => {
return client.onStatus((status) => {
const startedAt = (() => {
@@ -1800,7 +1976,10 @@ function Shell({
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onTurnEnd = useDeferredTitleRefresh(
temporaryChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@@ -1890,7 +2069,9 @@ function Shell({
});
}, []);
const headerTitle = activeSession
const headerTitle = temporaryChatActive
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
: activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1928,11 +2109,13 @@ function Shell({
const sidebarProps = {
sessions,
temporarySessions: temporarySessionList,
activeKey: view === "chat" ? activeKey : null,
loading,
newChatActive: view === "chat" && activeKey === null,
onNewChat,
onSelect: onSelectChat,
onCloseTemporaryChat,
onRequestDelete,
onTogglePin,
onRequestRename,
@@ -2118,10 +2301,16 @@ function Shell({
session={activeSession}
sessions={sessions}
title={headerTitle}
temporary={temporaryChatRequested}
temporaryChatIds={temporaryChatIds}
temporaryChatEnabled={temporaryChatEnabled}
onTemporaryChatEnabledChange={
!activeKey ? onTemporaryChatEnabledChange : undefined
}
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
onForkChat={temporaryChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2200,15 +2389,17 @@ function Shell({
</Suspense>
) : null}
{restartToast ? (
<div className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 flex w-[min(32rem,calc(100vw-1rem))] -translate-x-1/2 flex-col items-center gap-2">
<div
role="status"
className={cn(
floatingSurfaceElevationClassName,
"fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium",
"max-w-full rounded-full px-4 py-2 text-sm font-medium",
)}
>
{restartToast}
</div>
</div>
) : null}
<PairingCodePopup
requests={visiblePairingRequests}
+92 -2
View File
@@ -4,17 +4,20 @@ import {
useMemo,
useRef,
useState,
type RefObject,
} from "react";
import {
Archive,
ArchiveRestore,
Folder,
MessageCircleDashed,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
X,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -41,6 +44,7 @@ import {
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@@ -50,8 +54,10 @@ const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
interface ChatListProps {
sessions: ChatSummary[];
temporarySessions?: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
@@ -81,8 +87,10 @@ interface ChatListProps {
export const ChatList = memo(function ChatList({
sessions,
temporarySessions = [],
activeKey,
onSelect,
onCloseTemporaryChat,
onRequestDelete,
onTogglePin,
onRequestRename,
@@ -188,7 +196,7 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
@@ -196,7 +204,7 @@ export const ChatList = memo(function ChatList({
);
}
if (sessions.length === 0) {
if (sessions.length === 0 && temporarySessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
@@ -237,6 +245,16 @@ export const ChatList = memo(function ChatList({
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
>
{temporarySessions.length > 0 ? (
<TemporaryChatSection
sessions={temporarySessions}
activeKey={activeKey}
activeRowRef={activeRowRef}
running={running}
onSelect={onSelect}
onClose={onCloseTemporaryChat}
/>
) : null}
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
@@ -497,6 +515,78 @@ export const ChatList = memo(function ChatList({
);
});
function TemporaryChatSection({
sessions,
activeKey,
activeRowRef,
running,
onSelect,
onClose,
}: {
sessions: ChatSummary[];
activeKey: string | null;
activeRowRef: RefObject<HTMLDivElement>;
running: ReadonlySet<string>;
onSelect: (key: string) => void;
onClose?: (key: string) => void;
}) {
const { t } = useTranslation();
return (
<section aria-label={t("temporaryChat.sectionTitle")} className="relative z-[1]">
<ChatsGroupHeader label={t("temporaryChat.sectionTitle")} />
<ul className="space-y-0.5">
{sessions.map((session) => {
const active = session.key === activeKey;
const title = deriveTemporaryChatTitle(session.preview, t("temporaryChat.title"));
return (
<li key={session.key} className="min-w-0">
<div
ref={active ? activeRowRef : undefined}
data-temporary-chat-row={session.key}
className={cn(
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
)}
>
<button
type="button"
onClick={() => onSelect(session.key)}
aria-current={active ? "page" : undefined}
title={title}
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left"
>
<MessageCircleDashed
className="h-3.5 w-3.5 shrink-0 text-[hsl(var(--temporary-foreground))]"
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
</button>
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
{onClose ? (
<button
type="button"
aria-label={t("temporaryChat.closeAction", { title })}
onClick={() => onClose(session.key)}
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
>
<X className="h-3.5 w-3.5" aria-hidden />
</button>
) : null}
</div>
</li>
);
})}
</ul>
</section>
);
}
function ProjectGroupHeader({
label,
path,
+9 -3
View File
@@ -52,6 +52,8 @@ import type {
interface MessageBubbleProps {
message: UIMessage;
/** Give temporary-chat user turns the dashed private-mode treatment. */
temporary?: boolean;
/** When false, hide this message's copy button. Default true. */
showCopyAction?: boolean;
cliApps?: CliAppInfo[];
@@ -258,6 +260,7 @@ function UserDeliveryStatus({
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
export function MessageBubble({
message,
temporary = false,
showCopyAction = true,
cliApps = [],
mcpPresets = [],
@@ -326,9 +329,13 @@ export function MessageBubble({
) : null}
{hasText ? (
<p
data-temporary-message={temporary ? "true" : undefined}
className={cn(
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] bg-secondary/70 px-4 py-2",
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
temporary
? "border border-dashed border-muted-foreground/40 bg-transparent"
: "bg-secondary/70",
)}
>
{messageText}
@@ -724,8 +731,7 @@ function UserImageCell({
aria-label={image.name ? `${openLabel}: ${image.name}` : openLabel}
className={cn(
tileClasses,
"block cursor-zoom-in p-0 transition-transform duration-150 motion-reduce:transition-none",
"hover:scale-[1.01] hover:ring-2 hover:ring-primary/25",
"block cursor-zoom-in p-0",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
)}
>
+4
View File
@@ -31,11 +31,13 @@ import { cn } from "@/lib/utils";
interface SidebarProps {
sessions: ChatSummary[];
temporarySessions?: ChatSummary[];
activeKey: string | null;
loading: boolean;
newChatActive: boolean;
onNewChat: () => void;
onSelect: (key: string) => void;
onCloseTemporaryChat?: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
@@ -221,10 +223,12 @@ export function Sidebar(props: SidebarProps) {
{!collapsed && (
<ChatList
sessions={props.sessions}
temporarySessions={props.temporarySessions}
activeKey={props.activeKey}
loading={props.loading}
emptyLabel={t("chat.noSessions")}
onSelect={props.onSelect}
onCloseTemporaryChat={props.onCloseTemporaryChat}
onRequestDelete={props.onRequestDelete}
onTogglePin={props.onTogglePin}
onRequestRename={props.onRequestRename}
+147 -1
View File
@@ -139,6 +139,7 @@ import {
startApiService,
stopApiService,
updateAutomation,
updateComputerUseSettings,
updateImageGenerationSettings,
updateMcpServerTools,
updateModelCallOrder,
@@ -179,6 +180,7 @@ import type {
AutomationUpdatePayload,
CliAppInfo,
CliAppsPayload,
ComputerUseSettingsUpdate,
ImageGenerationSettingsUpdate,
McpPresetInfo,
McpPresetsPayload,
@@ -762,6 +764,7 @@ export function SettingsView({
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
const [networkSafetySaving, setNetworkSafetySaving] = useState(false);
const [computerUseSaving, setComputerUseSaving] = useState<"browser" | "computer" | null>(null);
const [apiService, setApiService] = useState<ApiServicePayload | null>(null);
const [apiServiceLoading, setApiServiceLoading] = useState(false);
const [apiServiceAction, setApiServiceAction] = useState<"start" | "stop" | null>(null);
@@ -1002,7 +1005,7 @@ export function SettingsView({
useEffect(() => {
if (
!pageVisible
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|| !["channels", "models", "browser", "runtime", "advanced"].includes(activeSection)
) {
return;
}
@@ -1559,6 +1562,34 @@ export function SettingsView({
}
};
const setComputerUseEnabled = async (
target: "browser" | "computer",
enabled: boolean,
) => {
if (!settings || computerUseSaving) return;
setComputerUseSaving(target);
try {
if (enabled && !(await installCapabilities(["computer-use"]))) return;
const update: ComputerUseSettingsUpdate = target === "browser"
? { browserEnabled: enabled }
: { computerEnabled: enabled };
const payload = await updateComputerUseSettings(token, update);
applyPayload(payload);
if (payload.requires_restart) {
setPendingRestartSections((prev) => ({
...prev,
[target === "browser" ? "browser" : "runtime"]: true,
}));
}
await maybeRestartHostEngine(payload);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setComputerUseSaving(null);
}
};
const handleApiServiceAction = async (
action: "start" | "stop",
values?: { host: string; port: number; timeout: number; apiKey?: string },
@@ -2238,6 +2269,11 @@ export function SettingsView({
olostepFeature={featureCatalog.find((feature) => feature.name === "olostep")}
olostepInstalling={nanobotFeatureAction === "enable:olostep"}
capabilityError={nanobotFeaturesError}
browserAutomationEnabled={settings.computer_use?.browser_enabled ?? false}
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
computerUseSaving={computerUseSaving === "browser"}
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
onToggleBrowserAutomation={(enabled) => void setComputerUseEnabled("browser", enabled)}
/>
);
case "channels":
@@ -2364,6 +2400,13 @@ export function SettingsView({
onRestart={restartViaSettingsSurface}
isRestarting={isRestarting || hostEngineApplying}
requiresRestartPending={pendingRestartSections.runtime}
computerControlEnabled={settings.computer_use?.enabled ?? false}
computerUseBackend={settings.computer_use?.backend ?? "desktop"}
computerUseFeature={featureCatalog.find((feature) => feature.name === "computer-use")}
computerUseSaving={computerUseSaving === "computer"}
computerUseInstalling={nanobotFeatureAction === "enable:computer-use"}
capabilityError={nanobotFeaturesError}
onToggleComputerControl={(enabled) => void setComputerUseEnabled("computer", enabled)}
/>
);
default:
@@ -5255,6 +5298,11 @@ function WebSettings({
olostepFeature,
olostepInstalling,
capabilityError,
browserAutomationEnabled,
computerUseFeature,
computerUseSaving,
computerUseInstalling,
onToggleBrowserAutomation,
}: {
settings: SettingsPayload;
form: WebSearchSettingsUpdate;
@@ -5274,6 +5322,11 @@ function WebSettings({
olostepFeature?: NanobotFeatureInfo;
olostepInstalling: boolean;
capabilityError: string | null;
browserAutomationEnabled: boolean;
computerUseFeature?: NanobotFeatureInfo;
computerUseSaving: boolean;
computerUseInstalling: boolean;
onToggleBrowserAutomation: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
@@ -5302,9 +5355,46 @@ function WebSettings({
: selectedProvider?.credential === "base_url"
? !baseUrl
: false;
const computerUseInstalled = computerUseFeature?.installed ?? true;
const browserAutomationDescription = computerUseInstalling
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
: computerUseInstalled
? tx(
"settings.help.browserAutomation",
"Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
)
: tx(
"settings.help.computerUseInstall",
"Required Python support will be installed when you turn this on.",
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.sections.browserAutomation", "Browser automation")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.browserAutomation", "Browser automation")}
description={browserAutomationDescription}
>
<ToggleButton
checked={browserAutomationEnabled}
disabled={computerUseSaving || computerUseInstalling}
onChange={onToggleBrowserAutomation}
ariaLabel={tx("settings.rows.browserAutomation", "Browser automation")}
label={browserAutomationEnabled
? tx("settings.values.on", "On")
: tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
{capabilityError ? (
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
) : null}
</section>
<section>
<SettingsSectionTitle>{tx("settings.sections.webSearch", "Web search")}</SettingsSectionTitle>
{form.provider === "olostep" && olostepFeature && !olostepFeature.installed ? (
@@ -8765,6 +8855,13 @@ function AdvancedSettings({
onSave,
onRestart,
isRestarting,
computerControlEnabled,
computerUseBackend,
computerUseFeature,
computerUseSaving,
computerUseInstalling,
capabilityError,
onToggleComputerControl,
}: {
form: NetworkSafetySettingsUpdate;
dirty: boolean;
@@ -8775,11 +8872,60 @@ function AdvancedSettings({
onSave: () => void;
onRestart?: () => void;
isRestarting?: boolean;
computerControlEnabled: boolean;
computerUseBackend: "desktop" | "browser";
computerUseFeature?: NanobotFeatureInfo;
computerUseSaving: boolean;
computerUseInstalling: boolean;
capabilityError: string | null;
onToggleComputerControl: (enabled: boolean) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const computerUseInstalled = computerUseFeature?.installed ?? true;
const computerControlDescription = computerUseInstalling
? tx("settings.help.computerUseInstalling", "Installing computer-use support...")
: !computerUseInstalled
? tx(
"settings.help.computerUseInstall",
"Required Python support will be installed when you turn this on.",
)
: computerUseBackend === "browser"
? tx(
"settings.help.computerControlBrowser",
"Pixel-based control currently targets an isolated browser, as configured in config.json.",
)
: tx(
"settings.help.computerControl",
"Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
);
return (
<div className="space-y-7">
<section>
<SettingsSectionTitle>
{tx("settings.sections.computerControl", "Computer control")}
</SettingsSectionTitle>
<SettingsGroup>
<SettingsRow
title={tx("settings.rows.computerControl", "Computer control")}
description={computerControlDescription}
>
<ToggleButton
checked={computerControlEnabled}
disabled={computerUseSaving || computerUseInstalling}
onChange={onToggleComputerControl}
ariaLabel={tx("settings.rows.computerControl", "Computer control")}
label={computerControlEnabled
? tx("settings.values.on", "On")
: tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
{capabilityError ? (
<p className="mt-2 px-1 text-[12px] text-destructive">{capabilityError}</p>
) : null}
</section>
<section>
<SettingsSectionTitle>
{isNativeHostSurface
+25 -5
View File
@@ -7,6 +7,7 @@ import {
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type Ref,
} from "react";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
@@ -199,12 +200,14 @@ interface ThreadComposerProps {
sessions?: ChatSummary[];
skills?: SkillSummary[];
onStop?: () => void;
surfaceRef?: Ref<HTMLDivElement>;
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
/** Unix seconds from server; turn elapsed timer above input while set. */
runStartedAt?: number | null;
/** Sustained objective for this chat (WebSocket ``goal_state``). */
goalState?: GoalStateWsPayload;
workspaceScope?: WorkspaceScopePayload | null;
workspaceControlsHidden?: boolean;
workspaceDefaultScope?: WorkspaceScopePayload | null;
workspaceControls?: WorkspacesPayload["controls"] | null;
workspaceScopeDisabled?: boolean;
@@ -951,10 +954,12 @@ export function ThreadComposer({
sessions = [],
skills = [],
onStop,
surfaceRef,
onTranscribeAudio,
runStartedAt = null,
goalState,
workspaceScope = null,
workspaceControlsHidden = false,
workspaceDefaultScope = null,
workspaceControls = null,
workspaceScopeDisabled = false,
@@ -1005,17 +1010,18 @@ export function ThreadComposer({
() => queuedPromptsStorageKey(pendingQueueKey),
[pendingQueueKey],
);
const showProjectPicker =
const projectPickerAvailable =
isHero
&& !!workspaceDefaultScope
&& !!onWorkspaceScopeChange
&& workspaceControls?.can_change_project !== false;
const showProjectPicker = projectPickerAvailable && !workspaceControlsHidden;
useEffect(() => {
secondEnterPromptIdRef.current = null;
skipQueuedPromptPersistRef.current = true;
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
}, [queuedPromptStorageKey]);
}, [pendingQueueKey, queuedPromptStorageKey]);
useEffect(() => {
if (!queuedPromptStorageKey) return;
@@ -2241,6 +2247,7 @@ export function ThreadComposer({
/>
) : null}
<div
ref={surfaceRef}
className={cn(
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
isHero
@@ -2386,7 +2393,7 @@ export function ThreadComposer({
) : null}
<div
className={cn(
"thread-composer-footer flex flex-nowrap items-center",
"thread-composer-footer flex flex-nowrap items-center motion-safe:transition-[padding-bottom] motion-safe:[transition-duration:220ms] motion-safe:ease-in-out",
isHero
? cn(
"gap-x-1.5 px-3 sm:px-4",
@@ -2433,7 +2440,7 @@ export function ThreadComposer({
isHero={isHero}
levels={voiceRecorder.levels}
/>
) : workspaceScope ? (
) : workspaceScope && !workspaceControlsHidden ? (
<WorkspaceAccessMenu
scope={workspaceScope}
disabled={disabled || workspaceScopeDisabled}
@@ -2544,9 +2551,18 @@ export function ThreadComposer({
</Button>
</div>
</div>
{projectPickerAvailable ? (
<div
className="composer-workspace-drawer"
data-composer-workspace-drawer=""
data-state={showProjectPicker ? "open" : "closed"}
aria-hidden={showProjectPicker ? undefined : true}
>
<div className="composer-workspace-drawer-clip">
<div className="composer-workspace-drawer-content">
<WorkspaceProjectPicker
isHero={isHero}
disabled={disabled || workspaceScopeDisabled}
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
scope={workspaceScope}
defaultScope={workspaceDefaultScope}
controls={workspaceControls}
@@ -2554,6 +2570,10 @@ export function ThreadComposer({
onChange={onWorkspaceScopeChange}
/>
</div>
</div>
</div>
) : null}
</div>
</form>
);
}
+64 -3
View File
@@ -1,8 +1,14 @@
import { Menu, Moon, Sun } from "lucide-react";
import type { ReactNode } from "react";
import { Menu, MessageCircleDashed, Moon, Sun } from "lucide-react";
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
interface ThreadHeaderProps {
@@ -16,6 +22,9 @@ interface ThreadHeaderProps {
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
temporaryChatEnabled?: boolean;
temporaryChatDisabled?: boolean;
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
}
export function ThreadHeader({
@@ -29,13 +38,17 @@ export function ThreadHeader({
minimal = false,
promptNavigatorAction,
sessionInfoAction,
temporaryChatEnabled = false,
temporaryChatDisabled = false,
onTemporaryChatEnabledChange,
}: ThreadHeaderProps) {
const { t } = useTranslation();
return (
<div
data-testid="thread-header"
className={cn(
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
minimal && "h-11",
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
)}
@@ -63,6 +76,54 @@ export function ThreadHeader({
<div className="ml-auto flex shrink-0 items-center gap-1">
{sessionInfoAction}
{promptNavigatorAction}
{onTemporaryChatEnabledChange ? (
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
disabled={temporaryChatDisabled}
aria-label={t("temporaryChat.title")}
aria-pressed={temporaryChatEnabled}
onClick={() => onTemporaryChatEnabledChange(!temporaryChatEnabled)}
className={cn(
"host-no-drag h-8 w-8 shrink-0 rounded-full bg-transparent text-muted-foreground shadow-none transition-none hover:text-foreground",
temporaryChatEnabled ? "hover:bg-transparent" : "hover:bg-accent/45",
)}
>
<MessageCircleDashed
data-testid="temporary-chat-icon"
className={cn(
"h-4 w-4 motion-safe:transition-colors",
temporaryChatEnabled
? "text-[var(--temporary-control-active)] motion-safe:duration-150"
: "text-current motion-safe:duration-75",
)}
aria-hidden
/>
</Button>
</TooltipTrigger>
<TooltipContent
side="bottom"
align="end"
className="max-w-72 rounded-xl border border-border/70 bg-popover px-3 py-2 text-[12px]/[1.4] text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10"
>
<div className="font-medium">{t("temporaryChat.title")}</div>
<div className="mt-1 text-muted-foreground">
{t("temporaryChat.retention")}
</div>
<div className="mt-1 font-medium">
{t("temporaryChat.expiration")}
</div>
<div className="mt-1 text-muted-foreground">
{t("temporaryChat.externalEffects")}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : null}
{!hideThemeButton ? (
<ThemeButton
theme={theme}
@@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/t
interface ThreadMessagesProps {
messages: UIMessage[];
temporary?: boolean;
/** When true, agent turn still in flight — keeps activity timeline expanded. */
isStreaming?: boolean;
hiddenUserMessageCount?: number;
@@ -50,6 +51,7 @@ export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
export function ThreadMessages({
messages,
temporary = false,
isStreaming = false,
hiddenUserMessageCount = 0,
cliApps = [],
@@ -125,6 +127,7 @@ export function ThreadMessages({
forkIndex={forkIndex}
showForkBoundary={index === forkBoundaryAfterUnitIndex}
forkBoundaryLabel={t("thread.forkedFromHistory")}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -147,6 +150,7 @@ interface ThreadDisplayUnitProps {
forkIndex?: number;
showForkBoundary: boolean;
forkBoundaryLabel: string;
temporary: boolean;
cliApps: CliAppInfo[];
mcpPresets: McpPresetInfo[];
slashCommands: SlashCommand[];
@@ -164,6 +168,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
forkIndex,
showForkBoundary,
forkBoundaryLabel,
temporary,
cliApps,
mcpPresets,
slashCommands,
@@ -200,6 +205,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
) : (
<MessageBubble
message={unit.message}
temporary={temporary}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
@@ -227,6 +233,7 @@ function threadDisplayUnitPropsEqual(
&& previous.forkIndex === next.forkIndex
&& previous.showForkBoundary === next.showForkBoundary
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
&& previous.temporary === next.temporary
&& previous.cliApps === next.cliApps
&& previous.mcpPresets === next.mcpPresets
&& previous.slashCommands === next.slashCommands
+62 -18
View File
@@ -295,10 +295,17 @@ interface ThreadShellProps {
session: ChatSummary | null;
sessions?: ChatSummary[];
title: string;
temporary?: boolean;
temporaryChatIds?: readonly string[];
temporaryChatEnabled?: boolean;
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
onToggleSidebar: () => void;
onGoHome?: () => void;
onNewChat?: () => void;
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
onCreateChat?: (
workspaceScope?: WorkspaceScopePayload | null,
initialMessage?: string,
) => Promise<string | null>;
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
@@ -477,7 +484,7 @@ function HeroGreeting({ text }: { text: string }) {
<h1
ref={headingRef}
data-testid="hero-greeting"
className="whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
className="select-none whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
>
{text}
</h1>
@@ -580,6 +587,10 @@ export function ThreadShell({
session,
sessions = [],
title,
temporary = false,
temporaryChatIds = [],
temporaryChatEnabled = false,
onTemporaryChatEnabledChange,
onToggleSidebar,
onCreateChat,
onForkChat,
@@ -602,7 +613,7 @@ export function ThreadShell({
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const historyKey = temporary ? null : session?.key ?? null;
const mentionSessions = useMemo(
() => sessions.filter((candidate) => (
candidate.key !== historyKey
@@ -657,6 +668,7 @@ export function ThreadShell({
const [quotedContext, setQuotedContext] = useState<string | null>(null);
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
const shellRef = useRef<HTMLElement | null>(null);
const composerSurfaceRef = useRef<HTMLDivElement | null>(null);
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
const filePreviewCloseTimerRef = useRef<number | null>(null);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -664,6 +676,7 @@ export function ThreadShell({
const viewportRef = useRef<ThreadViewportHandle | null>(null);
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const knownTemporaryChatIdsRef = useRef(new Set<string>());
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
const prevChatIdForCacheRef = useRef<string | null>(null);
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
@@ -678,6 +691,8 @@ export function ThreadShell({
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
const uiRevisionRef = useRef(0);
const showTemporaryChatControl =
!hideHeader && !session && !loading && !!onTemporaryChatEnabledChange;
const initial = useMemo(() => {
if (!chatId) return historical;
@@ -736,6 +751,18 @@ export function ThreadShell({
setSubmittedViewportTurnId(null);
}, [historyKey]);
useEffect(() => {
const retained = new Set(temporaryChatIds);
for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
for (const cachedChatId of knownTemporaryChatIdsRef.current) {
if (!retained.has(cachedChatId)) {
messageCacheRef.current.delete(cachedChatId);
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
knownTemporaryChatIdsRef.current.delete(cachedChatId);
}
}
}, [temporaryChatIds]);
const handleQuoteSelection = useCallback((text: string) => {
setQuotedContext(text);
setComposerFocusSignal((value) => value + 1);
@@ -838,6 +865,12 @@ export function ThreadShell({
() => modelPresetOptionsFromSettings(settings),
[settings],
);
const availableSlashCommands = useMemo(
() => temporary
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
: slashCommands,
[slashCommands, temporary],
);
const modelBadge = useMemo(
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
[activeModelPreset, modelName, settings],
@@ -898,7 +931,7 @@ export function ThreadShell({
}, [chatId, client]);
useEffect(() => {
if (!chatId || loading) return;
if (!historyKey || !chatId || loading) return;
const cached = messageCacheRef.current.get(chatId);
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
const hasNewCanonicalHistory = (
@@ -1028,10 +1061,11 @@ export function ThreadShell({
historyLineage,
historyActiveTurnId,
hasPendingToolCalls,
historyKey,
]);
useLayoutEffect(() => {
if (!chatId) return;
if (!historyKey || !chatId) return;
const commit = pendingCanonicalCommitRef.current.get(chatId);
if (!commit) return;
if (
@@ -1069,17 +1103,17 @@ export function ThreadShell({
pendingCanonicalCommitRef.current.delete(chatId);
committedHistoryLineageRef.current.set(chatId, historyLineage);
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
useEffect(() => {
if (!chatId || hasPendingToolCalls) return;
if (!historyKey || !chatId || hasPendingToolCalls) return;
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
completedCanonicalHydrateVersionRef.current.delete(chatId);
reconcileTurnComplete();
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
const refreshCanonicalHistory = useCallback(() => {
if (!chatId) return;
if (!historyKey || !chatId) return;
pendingCanonicalHydrateRef.current.set(chatId, {
historyLineage,
historyVersion,
@@ -1089,10 +1123,10 @@ export function ThreadShell({
uiRevision: uiRevisionRef.current,
});
refreshHistory();
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
useEffect(() => {
if (!chatId) return;
if (!historyKey || !chatId) return;
return client.onSessionUpdate((updatedChatId, scope) => {
if (updatedChatId !== chatId) return;
if (scope === "metadata") return;
@@ -1101,7 +1135,7 @@ export function ThreadShell({
// so keep an active programmatic follow alive across canonical hydration.
refreshCanonicalHistory();
});
}, [chatId, client, refreshCanonicalHistory]);
}, [chatId, client, historyKey, refreshCanonicalHistory]);
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
useEffect(() => {
@@ -1112,7 +1146,7 @@ export function ThreadShell({
}
if (!wasPageHiddenRef.current) return;
wasPageHiddenRef.current = false;
if (!chatId || client.status !== "open" || loading) return;
if (!historyKey || !chatId || client.status !== "open" || loading) return;
if (
!turnActive
&& !hasPendingToolCalls
@@ -1129,6 +1163,7 @@ export function ThreadShell({
chatId,
client,
hasPendingToolCalls,
historyKey,
historyError,
loading,
refreshCanonicalHistory,
@@ -1230,7 +1265,7 @@ export function ThreadShell({
setBooting(true);
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
setPendingFirstTargetChatId(null);
const newId = await onCreateChat?.(workspaceScope);
const newId = await onCreateChat?.(workspaceScope, content);
if (!newId) {
pendingFirstRef.current = null;
setPendingFirstTargetChatId(null);
@@ -1386,7 +1421,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
sessions={mentionSessions}
@@ -1396,12 +1431,13 @@ export function ThreadShell({
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceControlsHidden={temporary}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
workspaceError={workspaceError}
onWorkspaceScopeChange={onWorkspaceScopeChange}
pendingQueueKey={chatId}
pendingQueueKey={temporary ? null : chatId}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
quotedContext={quotedContext}
@@ -1429,15 +1465,17 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
sessions={mentionSessions}
skills={skills}
surfaceRef={composerSurfaceRef}
runStartedAt={currentRunStartedAt}
onTranscribeAudio={transcribeAudio}
goalState={currentGoalState}
workspaceScope={workspaceScope}
workspaceControlsHidden={temporary}
workspaceDefaultScope={workspaceDefaultScope}
workspaceControls={workspaceControls}
workspaceScopeDisabled={workspaceScopeDisabled}
@@ -1484,6 +1522,11 @@ export function ThreadShell({
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
temporaryChatEnabled={temporaryChatEnabled}
temporaryChatDisabled={booting || turnActive}
onTemporaryChatEnabledChange={
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
}
/>
) : null}
<FilePreviewAvailabilityProvider
@@ -1492,6 +1535,7 @@ export function ThreadShell({
<ThreadViewport
ref={viewportRef}
messages={displayMessages}
temporary={temporary}
isStreaming={turnActive}
emptyState={emptyState}
composer={composer}
@@ -1502,7 +1546,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
@@ -35,6 +35,7 @@ export interface ThreadViewportHandle {
interface ThreadViewportProps {
messages: UIMessage[];
temporary?: boolean;
isStreaming: boolean;
composer: ReactNode;
emptyState?: ReactNode;
@@ -157,6 +158,7 @@ function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages,
temporary = false,
isStreaming,
composer,
emptyState,
@@ -682,6 +684,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages
messages={visibleMessages}
temporary={temporary}
isStreaming={isStreaming}
hiddenUserMessageCount={hiddenUserMessageCount}
cliApps={cliApps}
@@ -36,6 +36,8 @@ import {
export function WorkspaceProjectPicker({
isHero,
compact = false,
connected = false,
disabled,
scope,
defaultScope,
@@ -44,6 +46,8 @@ export function WorkspaceProjectPicker({
onChange,
}: {
isHero: boolean;
compact?: boolean;
connected?: boolean;
disabled?: boolean;
scope: WorkspaceScopePayload | null;
defaultScope: WorkspaceScopePayload | null;
@@ -74,8 +78,12 @@ export function WorkspaceProjectPicker({
}, [currentProjectScope?.project_path, open]);
useEffect(() => {
if (error && visible) setOpen(true);
}, [error, visible]);
if (disabled) setOpen(false);
}, [disabled]);
useEffect(() => {
if (error && visible && !disabled) setOpen(true);
}, [disabled, error, visible]);
const applyProjectPath = useCallback(
(projectPath: string, projectName?: string) => {
@@ -115,7 +123,11 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) {
return (
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
<div className={cn(
compact
? "inline-flex"
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
)}>
<button
type="button"
disabled={disabled || pickingFolder}
@@ -123,16 +135,18 @@ export function WorkspaceProjectPicker({
title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()}
className={cn(
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
compact
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
(connected || currentProjectScope) && "text-primary",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
</button>
{pathError || error ? (
{!compact && (pathError || error) ? (
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
{pathError ?? error}
</span>
@@ -142,7 +156,11 @@ export function WorkspaceProjectPicker({
}
return (
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
<div className={cn(
compact
? "inline-flex"
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
@@ -150,15 +168,19 @@ export function WorkspaceProjectPicker({
disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")}
className={cn(
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82",
compact
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
(connected || currentProjectScope) && "text-primary",
)}
>
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
<span className="truncate">{projectLabel}</span>
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
{!compact ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
</button>
</PopoverTrigger>
<PopoverContent
@@ -59,16 +59,6 @@ function truncatePreview(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
+1 -73
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Circle } from "lucide-react";
import {
floatingItemClassName,
@@ -12,52 +12,11 @@ import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuItemClassName =
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
menuItemClassName,
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
floatingSurfaceClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
portalContainer?: HTMLElement | null;
@@ -103,31 +62,6 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
@@ -183,17 +117,11 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
+43 -51
View File
@@ -33,6 +33,10 @@
--input: 40 8% 90.5%;
--ring: 0 0% 3.9%;
--inline-token-highlight: #ef8e30;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 17 88% 32%;
--temporary-border: 17 88% 40%;
--radius: 0.4375rem;
--sidebar: 40 8% 96.8%;
--sidebar-foreground: 0 0% 3.9%;
@@ -67,6 +71,10 @@
--input: var(--border);
--ring: 0 0% 83.1%;
--inline-token-highlight: #ef8e30;
--temporary-control-active: #ef8e30;
--temporary-accent: 24 95% 53%;
--temporary-foreground: 32 98% 73%;
--temporary-border: 27 96% 61%;
--sidebar: var(--card);
--sidebar-foreground: 0 0% 98%;
--sidebar-accent: var(--background);
@@ -235,10 +243,6 @@
height: 0;
}
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
/* Keep the outer document rhythm clean at message boundaries. */
.markdown-content > :first-child {
@apply mt-0;
@@ -338,7 +342,6 @@
animation: none;
content: "";
}
.markdown-content-streaming > :last-child::after,
.streaming-text-fallback::after {
animation: none;
}
@@ -435,6 +438,41 @@
opacity: 1;
transform: translateY(0);
}
.composer-workspace-drawer {
--composer-workspace-drawer-duration: 220ms;
display: grid;
grid-template-rows: 0fr;
opacity: 0;
pointer-events: none;
}
.composer-workspace-drawer[data-state="open"] {
--composer-workspace-drawer-duration: 240ms;
grid-template-rows: 1fr;
opacity: 1;
pointer-events: auto;
}
.composer-workspace-drawer-clip {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: no-preference) {
.composer-workspace-drawer {
transition:
grid-template-rows var(--composer-workspace-drawer-duration)
cubic-bezier(0.4, 0, 0.2, 1),
opacity var(--composer-workspace-drawer-duration) ease-in-out;
}
.composer-workspace-drawer-content {
transform: translateY(-6px);
transition: transform var(--composer-workspace-drawer-duration)
cubic-bezier(0.4, 0, 0.2, 1);
}
.composer-workspace-drawer[data-state="open"] .composer-workspace-drawer-content {
transform: translateY(0);
}
}
@keyframes run-pulse-dot {
0%,
100% {
@@ -565,52 +603,6 @@
}
}
@keyframes cli-app-linked-sheen {
0% {
transform: translateX(-140%) skewX(-14deg);
opacity: 0;
}
18% {
opacity: 0.7;
}
72%,
100% {
transform: translateX(140%) skewX(-14deg);
opacity: 0;
}
}
.cli-app-linked-chip::after {
content: "";
position: absolute;
inset: -1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.14) 46%,
hsl(var(--background) / 0.7) 50%,
hsl(var(--foreground) / 0.12) 54%,
transparent 100%
);
animation: cli-app-linked-sheen 1.25s ease-out 1;
}
.dark .cli-app-linked-chip::after {
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.12) 46%,
hsl(var(--background) / 0.5) 50%,
hsl(var(--foreground) / 0.1) 54%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.cli-app-linked-chip::after {
animation: none;
content: none;
}
}
/* Subtle scrollbar that doesn't fight the dark background. */
.scrollbar-thin {
scrollbar-width: thin;
-1
View File
@@ -55,7 +55,6 @@ export type AttachmentError =
| "io"; // file read failed at the browser layer
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;
+2
View File
@@ -1461,6 +1461,8 @@ export function useNanobotStream(
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
setRunStartedAt(null);
client.finishRunLocally(chatId);
client.sendMessage(chatId, "/stop");
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "No pending request matches this code."
}
},
"temporaryChat": {
"title": "Temporary chat",
"retention": "Not saved to history or memory.",
"expiration": "Reloading, closing, or losing the connection ends these chats.",
"externalEffects": "Requests still go to your model provider, and tool actions may leave changes.",
"clear": "Clear temporary chat",
"sectionTitle": "Temporary chats",
"closeAction": "Close temporary chat: {{title}}"
},
"sidebar": {
"navigation": "Sidebar navigation",
"collapse": "Collapse sidebar",
@@ -106,6 +115,8 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"browserAutomation": "Browser automation",
"computerControl": "Computer control",
"cliApps": "CLI apps",
"mcp": "MCP services",
"regional": "Regional",
@@ -190,6 +201,8 @@
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
"browserAutomation": "Browser automation",
"computerControl": "Computer control",
"imageGeneration": "Image generation",
"imageProvider": "Image provider",
"imageProviderStatus": "Provider status",
@@ -235,6 +248,11 @@
"maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.",
"browserAutomation": "Let nanobot navigate and act on web pages using structured page elements. Requires Playwright Chromium.",
"computerControl": "Let nanobot see and control the computer running its engine. macOS requires Screen Recording and Accessibility access.",
"computerControlBrowser": "Pixel-based control currently targets an isolated browser, as configured in config.json.",
"computerUseInstall": "Required Python support will be installed when you turn this on.",
"computerUseInstalling": "Installing computer-use support...",
"imageGeneration": "Expose generate_image in chats when a configured image provider is available.",
"imageProvider": "Choose the registry provider used by generate_image.",
"imageProviderStatus": "Image generation reuses provider credentials from Providers.",
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
}
},
"temporaryChat": {
"title": "Chat temporal",
"retention": "No se guarda en el historial ni en la memoria.",
"expiration": "Recargar, cerrar o perder la conexión finaliza estos chats.",
"externalEffects": "Las solicitudes siguen llegando al proveedor del modelo y las herramientas pueden dejar cambios.",
"clear": "Borrar chat temporal",
"sectionTitle": "Chats temporales",
"closeAction": "Cerrar chat temporal: {{title}}"
},
"sidebar": {
"navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral",
@@ -106,6 +115,8 @@
"imageDefaults": "Valores predeterminados",
"webSearch": "Búsqueda web",
"webBehavior": "Comportamiento",
"browserAutomation": "Automatización del navegador",
"computerControl": "Control del ordenador",
"regional": "Configuración regional",
"webuiSafety": "Seguridad de WebUI",
"capabilities": "Capacidades",
@@ -136,6 +147,8 @@
"maxResults": "Resultados máximos",
"timeout": "Tiempo de espera",
"jinaReader": "Lector Jina",
"browserAutomation": "Automatización del navegador",
"computerControl": "Control del ordenador",
"imageGeneration": "Generación de imágenes",
"imageProvider": "Proveedor de imágenes",
"imageProviderStatus": "Estado del proveedor",
@@ -179,6 +192,11 @@
"maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
"jinaReader": "Usa Jina Reader para web_fetch cuando esté disponible.",
"browserAutomation": "Permite que nanobot navegue y actúe en páginas web mediante elementos estructurados. Requiere Chromium de Playwright.",
"computerControl": "Permite que nanobot vea y controle el ordenador que ejecuta su motor. macOS requiere permisos de grabación de pantalla y accesibilidad.",
"computerControlBrowser": "El control por píxeles apunta actualmente a un navegador aislado, según config.json.",
"computerUseInstall": "El soporte de Python necesario se instalará al activarlo.",
"computerUseInstalling": "Instalando componentes de control...",
"imageGeneration": "Expone generate_image en chats cuando hay un proveedor de imagen configurado.",
"imageProvider": "Elige el proveedor registrado usado por generate_image.",
"imageProviderStatus": "La generación de imágenes reutiliza las credenciales de los proveedores.",
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "Aucune demande en attente ne correspond à ce code."
}
},
"temporaryChat": {
"title": "Discussion temporaire",
"retention": "Elle nest enregistrée ni dans lhistorique ni dans la mémoire.",
"expiration": "Recharger, fermer ou perdre la connexion met fin à ces discussions.",
"externalEffects": "Les requêtes sont tout de même envoyées au fournisseur du modèle et les outils peuvent laisser des modifications.",
"clear": "Effacer la discussion temporaire",
"sectionTitle": "Discussions temporaires",
"closeAction": "Fermer la discussion temporaire : {{title}}"
},
"sidebar": {
"navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale",
@@ -106,6 +115,8 @@
"imageDefaults": "Valeurs par défaut",
"webSearch": "Recherche web",
"webBehavior": "Comportement",
"browserAutomation": "Automatisation du navigateur",
"computerControl": "Contrôle de lordinateur",
"regional": "Paramètres régionaux",
"webuiSafety": "Sécurité WebUI",
"capabilities": "Capacités",
@@ -136,6 +147,8 @@
"maxResults": "Résultats max.",
"timeout": "Délai dattente",
"jinaReader": "Lecteur Jina",
"browserAutomation": "Automatisation du navigateur",
"computerControl": "Contrôle de lordinateur",
"imageGeneration": "Génération dimages",
"imageProvider": "Fournisseur dimages",
"imageProviderStatus": "État du fournisseur",
@@ -179,6 +192,11 @@
"maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
"jinaReader": "Utilise Jina Reader pour web_fetch lorsque disponible.",
"browserAutomation": "Permet à nanobot de parcourir et manipuler les pages web à partir de leurs éléments structurés. Nécessite Chromium de Playwright.",
"computerControl": "Permet à nanobot de voir et contrôler lordinateur qui exécute son moteur. macOS exige les autorisations Enregistrement de l’écran et Accessibilité.",
"computerControlBrowser": "Le contrôle par pixels cible actuellement un navigateur isolé, conformément à config.json.",
"computerUseInstall": "Les composants Python requis seront installés lors de lactivation.",
"computerUseInstalling": "Installation des composants de contrôle...",
"imageGeneration": "Expose generate_image dans les chats lorsquun fournisseur dimage configuré est disponible.",
"imageProvider": "Choisissez le fournisseur inscrit utilisé par generate_image.",
"imageProviderStatus": "La génération dimages réutilise les identifiants des fournisseurs.",
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "Tidak ada permintaan tertunda yang cocok dengan kode ini."
}
},
"temporaryChat": {
"title": "Obrolan sementara",
"retention": "Tidak disimpan ke riwayat atau memori.",
"expiration": "Memuat ulang, menutup, atau kehilangan koneksi akan mengakhiri obrolan ini.",
"externalEffects": "Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
"clear": "Hapus obrolan sementara",
"sectionTitle": "Obrolan sementara",
"closeAction": "Tutup obrolan sementara: {{title}}"
},
"sidebar": {
"navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar",
@@ -106,6 +115,8 @@
"imageDefaults": "Bawaan",
"webSearch": "Pencarian web",
"webBehavior": "Perilaku",
"browserAutomation": "Otomatisasi browser",
"computerControl": "Kontrol komputer",
"regional": "Regional",
"webuiSafety": "Keamanan WebUI",
"capabilities": "Kemampuan",
@@ -136,6 +147,8 @@
"maxResults": "Hasil maksimum",
"timeout": "Batas waktu",
"jinaReader": "Pembaca Jina",
"browserAutomation": "Otomatisasi browser",
"computerControl": "Kontrol komputer",
"imageGeneration": "Pembuatan gambar",
"imageProvider": "Penyedia gambar",
"imageProviderStatus": "Status penyedia",
@@ -179,6 +192,11 @@
"maxResults": "Hasil yang dikembalikan oleh setiap panggilan web_search.",
"timeout": "Detik sebelum permintaan penyedia pencarian mencapai batas waktu.",
"jinaReader": "Gunakan Jina Reader untuk web_fetch jika tersedia.",
"browserAutomation": "Izinkan nanobot menjelajah dan bertindak pada halaman web melalui elemen terstruktur. Memerlukan Chromium dari Playwright.",
"computerControl": "Izinkan nanobot melihat dan mengontrol komputer yang menjalankan mesinnya. macOS memerlukan izin Perekaman Layar dan Aksesibilitas.",
"computerControlBrowser": "Kontrol berbasis piksel saat ini menargetkan browser terisolasi sesuai config.json.",
"computerUseInstall": "Dukungan Python yang diperlukan akan dipasang saat diaktifkan.",
"computerUseInstalling": "Memasang komponen kontrol komputer...",
"imageGeneration": "Tampilkan generate_image di chat saat penyedia gambar yang dikonfigurasi tersedia.",
"imageProvider": "Pilih penyedia registry yang digunakan oleh generate_image.",
"imageProviderStatus": "Pembuatan gambar menggunakan kembali kredensial penyedia dari bagian Penyedia.",
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "このコードに一致する保留中のリクエストはありません。"
}
},
"temporaryChat": {
"title": "一時チャット",
"retention": "履歴やメモリには保存されません。",
"expiration": "再読み込み、ページを閉じる操作、接続切断で一時チャットは終了します。",
"externalEffects": "リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
"clear": "一時チャットを消去",
"sectionTitle": "一時チャット",
"closeAction": "一時チャット「{{title}}」を閉じる"
},
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
@@ -106,6 +115,8 @@
"imageDefaults": "既定値",
"webSearch": "ウェブ検索",
"webBehavior": "動作",
"browserAutomation": "ブラウザ自動操作",
"computerControl": "コンピュータ操作",
"regional": "地域",
"webuiSafety": "WebUI の安全性",
"capabilities": "機能",
@@ -136,6 +147,8 @@
"maxResults": "最大結果数",
"timeout": "タイムアウト",
"jinaReader": "Jina リーダー",
"browserAutomation": "ブラウザ自動操作",
"computerControl": "コンピュータ操作",
"imageGeneration": "画像生成",
"imageProvider": "画像プロバイダー",
"imageProviderStatus": "プロバイダー状態",
@@ -179,6 +192,11 @@
"maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
"jinaReader": "利用可能な場合、web_fetch に Jina Reader を使います。",
"browserAutomation": "構造化されたページ要素を使って、nanobot がウェブページを閲覧・操作できるようにします。Playwright Chromium が必要です。",
"computerControl": "nanobot がエンジンを実行しているコンピュータを表示・操作できるようにします。macOS では画面収録とアクセシビリティの許可が必要です。",
"computerControlBrowser": "ピクセル操作は現在、config.json の設定に従って分離ブラウザを対象としています。",
"computerUseInstall": "有効にすると必要な Python コンポーネントがインストールされます。",
"computerUseInstalling": "コンピュータ操作コンポーネントをインストール中...",
"imageGeneration": "画像プロバイダーが設定済みのとき、チャットで generate_image を有効にします。",
"imageProvider": "generate_image で使用する登録済みプロバイダーを選択します。",
"imageProviderStatus": "画像生成はプロバイダー設定の認証情報を再利用します。",
+18
View File
@@ -49,6 +49,15 @@
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
}
},
"temporaryChat": {
"title": "임시 채팅",
"retention": "기록이나 메모리에 저장되지 않습니다.",
"expiration": "새로고침, 페이지 닫기 또는 연결 끊김 시 임시 채팅이 종료됩니다.",
"externalEffects": "요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
"clear": "임시 채팅 지우기",
"sectionTitle": "임시 채팅",
"closeAction": "임시 채팅 닫기: {{title}}"
},
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
@@ -106,6 +115,8 @@
"imageDefaults": "기본값",
"webSearch": "웹 검색",
"webBehavior": "동작",
"browserAutomation": "브라우저 자동화",
"computerControl": "컴퓨터 제어",
"regional": "지역",
"webuiSafety": "WebUI 보안",
"capabilities": "기능",
@@ -136,6 +147,8 @@
"maxResults": "최대 결과 수",
"timeout": "타임아웃",
"jinaReader": "Jina 리더",
"browserAutomation": "브라우저 자동화",
"computerControl": "컴퓨터 제어",
"imageGeneration": "이미지 생성",
"imageProvider": "이미지 제공자",
"imageProviderStatus": "제공자 상태",
@@ -179,6 +192,11 @@
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
"jinaReader": "가능할 때 web_fetch에 Jina Reader를 사용합니다.",
"browserAutomation": "nanobot이 구조화된 페이지 요소를 사용해 웹페이지를 탐색하고 조작할 수 있게 합니다. Playwright Chromium이 필요합니다.",
"computerControl": "nanobot이 엔진을 실행하는 컴퓨터를 보고 제어할 수 있게 합니다. macOS에서는 화면 기록 및 손쉬운 사용 권한이 필요합니다.",
"computerControlBrowser": "픽셀 기반 제어는 현재 config.json 설정에 따라 격리된 브라우저를 대상으로 합니다.",
"computerUseInstall": "켜면 필요한 Python 구성 요소가 설치됩니다.",
"computerUseInstalling": "컴퓨터 제어 구성 요소 설치 중...",
"imageGeneration": "구성된 이미지 제공자가 있을 때 채팅에서 generate_image를 노출합니다.",
"imageProvider": "generate_image에 사용할 등록 제공자를 선택합니다.",
"imageProviderStatus": "이미지 생성은 제공자 자격 증명을 재사용합니다.",

Some files were not shown because too many files have changed in this diff Show More