Compare commits

..
Author SHA1 Message Date
Xubin Ren 5922c4ebea feat(providers): add xAI Grok OAuth support 2026-05-22 00:02:45 +08:00
124 changed files with 2619 additions and 15443 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }}
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }}
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python).
python-version: ${{ fromJSON('["3.13","3.14"]') }}
+1 -13
View File
@@ -1,18 +1,6 @@
![cover-v5-optimized](./images/GitHub_README.png)
<div align="center">
<p>
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview">English</a> |
<a href="https://nanobot.wiki/cn/docs/latest/getting-started/nanobot-overview">简体中文</a> |
<a href="https://nanobot.wiki/zh-Hant/docs/latest/getting-started/nanobot-overview">繁體中文</a> |
<a href="https://nanobot.wiki/es/docs/latest/getting-started/nanobot-overview">Español</a> |
<a href="https://nanobot.wiki/fr/docs/latest/getting-started/nanobot-overview">Français</a> |
<a href="https://nanobot.wiki/id/docs/latest/getting-started/nanobot-overview">Bahasa Indonesia</a> |
<a href="https://nanobot.wiki/ja/docs/latest/getting-started/nanobot-overview">日本語</a> |
<a href="https://nanobot.wiki/ko/docs/latest/getting-started/nanobot-overview">한국어</a> |
<a href="https://nanobot.wiki/ru/docs/latest/getting-started/nanobot-overview">Русский</a> |
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
</p>
<p>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a>
@@ -73,7 +61,7 @@
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-10** 📓 Notebook editing tool, multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
-67
View File
@@ -17,7 +17,6 @@ Connect nanobot to your favorite chat platform. Want to build your own? See the
| **Wecom** | Bot ID + Bot Secret |
| **Microsoft Teams** | App ID + App Password + public HTTPS endpoint |
| **Mochat** | Claw token (auto-setup available) |
| **Signal** | signal-cli daemon + phone number |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@@ -670,69 +669,3 @@ nanobot gateway
```
</details>
<details>
<summary><b>Signal</b></summary>
Uses **signal-cli** daemon in HTTP mode — receive messages via SSE, send via JSON-RPC.
**1. Install signal-cli**
Install [signal-cli](https://github.com/AsamK/signal-cli) and register a phone number:
```bash
signal-cli -u +1234567890 register
signal-cli -u +1234567890 verify <CODE>
```
Start the daemon:
```bash
signal-cli -a +1234567890 daemon --http localhost:8080
```
**2. Configure**
```json
{
"channels": {
"signal": {
"enabled": true,
"phoneNumber": "+1234567890",
"daemonHost": "localhost",
"daemonPort": 8080,
"dm": {
"enabled": true,
"policy": "open"
},
"group": {
"enabled": true,
"policy": "open",
"requireMention": true
}
}
}
}
```
> - `phoneNumber`: Your registered Signal phone number.
> - `daemonHost` / `daemonPort`: Where signal-cli daemon is listening (default `localhost:8080`).
> - `dm.policy`: `"open"` (anyone can DM) or `"allowlist"` (only listed numbers/UUIDs). When `"allowlist"`, unlisted DM senders receive a pairing code.
> - `dm.allowFrom`: List of allowed phone numbers or UUIDs (used when policy is `"allowlist"`).
> - `group.policy`: `"open"` (all groups) or `"allowlist"` (only listed group IDs).
> - `group.requireMention`: When `true` (default), the bot only responds in groups when @mentioned.
> - `group.allowFrom`: List of allowed group IDs (used when group policy is `"allowlist"`).
> - `attachmentsDir`: Override the directory where signal-cli stores inbound attachments. Defaults to `~/.local/share/signal-cli/attachments` (the Linux default). Set this if signal-cli runs with a custom `XDG_DATA_HOME` or on macOS/Windows.
> - `groupMessageBufferSize`: Number of recent group messages kept for context (default `20`, must be > 0).
**3. Run**
```bash
nanobot gateway
```
> [!TIP]
> The channel automatically reconnects to the signal-cli daemon with exponential backoff if the connection drops.
> Markdown in bot replies is automatically converted to Signal text styles (bold, italic, code, etc.).
</details>
-1
View File
@@ -148,7 +148,6 @@ ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) |
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
+3 -28
View File
@@ -23,7 +23,7 @@ The feature is disabled by default. Enable it in `~/.nanobot/config.json`, confi
}
```
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, Gemini, Ollama, and StepFun configuration examples.
See [Provider Notes](#provider-notes) for AIHubMix, MiniMax, and Gemini configuration examples.
> [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
@@ -46,7 +46,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun` |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Image provider name. Supported values: `openrouter`, `aihubmix`, `minimax`, `gemini`, `stepfun` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@@ -168,31 +168,6 @@ For reference-image edits, use a Gemini Flash image model:
Imagen 4 supports the aspect ratios `1:1`, `9:16`, `16:9`, `3:4`, and `4:3`. Unsupported ratios are ignored and the model uses its default. The `defaultImageSize` setting has no effect on Gemini models; sizing is controlled by `defaultAspectRatio` only. Reference images passed with an Imagen model are ignored (with a warning logged).
### Ollama
Ollama's experimental native image generation API works with local servers and hosted ollama.com models. Local access at `http://localhost:11434/api` does not require an API key; set `providers.ollama.apiKey` only when targeting `https://ollama.com/api`.
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/api"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "ollama",
"model": "x/z-image-turbo",
"defaultAspectRatio": "16:9",
"defaultImageSize": "2K"
}
}
}
```
Ollama maps `defaultAspectRatio` and `defaultImageSize` to native `width` and `height` values. Reference images are not supported by this integration.
### StepFun
StepFun (阶跃星辰) `step-image-edit-2` supports text-to-image generation. The `step-1x-medium` variant additionally supports **style-reference** image edits, where a reference image guides the visual style of the output.
@@ -299,7 +274,7 @@ Use the reference image. Keep the same robot and composition, change the palette
|---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, `ollama`, or `stepfun` |
| `unsupported image generation provider` | Use `openrouter`, `aihubmix`, `minimax`, `gemini`, or `stepfun` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |
+3 -9
View File
@@ -22,7 +22,7 @@ from nanobot.utils.prompt_templates import render_template
class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
@@ -47,8 +47,6 @@ class ContextBuilder:
if bootstrap:
parts.append(bootstrap)
parts.append(render_template("agent/tool_contract.md"))
memory = self.memory.get_memory_context()
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}")
@@ -156,14 +154,9 @@ class ContextBuilder:
sender_id: str | None = None,
session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None,
current_runtime_lines: Sequence[str] | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
extra = [
*goal_state_runtime_lines(session_metadata),
]
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
extra = goal_state_runtime_lines(session_metadata)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,
@@ -217,3 +210,4 @@ class ContextBuilder:
if not images:
return text
return images + [{"type": "text", "text": text}]
+4 -4
View File
@@ -28,7 +28,6 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.self import MyTool
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.cli_apps import utils as cli_app_utils
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
@@ -60,6 +59,7 @@ if TYPE_CHECKING:
UNIFIED_SESSION_KEY = "unified:default"
class TurnState(Enum):
RESTORE = auto()
COMPACT = auto()
@@ -568,7 +568,7 @@ class AgentLoop:
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
if has_text or media_paths:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | cli_app_utils.session_extra(msg.metadata)
extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {}
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
session.add_message("user", text, **extra)
@@ -593,7 +593,7 @@ class AgentLoop:
chat_id=self._runtime_chat_id(msg),
sender_id=msg.sender_id,
session_summary=pending_summary,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace),
session_metadata=session.metadata,
)
async def _dispatch_command_inline(
@@ -1058,7 +1058,7 @@ class AgentLoop:
current_role=current_role,
sender_id=msg.sender_id,
session_summary=pending,
session_metadata=session.metadata, current_runtime_lines=cli_app_utils.runtime_lines(msg, self.context.workspace, skip=is_subagent),
session_metadata=session.metadata,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
+13 -23
View File
@@ -19,8 +19,7 @@ from nanobot.utils.file_edit_events import (
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_tracker as _prepare_file_edit_tracker,
prepare_file_edit_trackers,
prepare_file_edit_tracker,
StreamingFileEditTracker,
)
from nanobot.utils.helpers import (
@@ -59,14 +58,11 @@ _SNIP_SAFETY_BUFFER = 1024
_MICROCOMPACT_KEEP_RECENT = 10
_MICROCOMPACT_MIN_CHARS = 500
_COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
"read_file", "exec", "grep",
"web_search", "web_fetch", "list_dir",
})
_BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@dataclass(slots=True)
@@ -861,8 +857,8 @@ class AgentRunner:
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_trackers = (
prepare_file_edit_trackers(
file_edit_tracker = (
prepare_file_edit_tracker(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
@@ -872,13 +868,13 @@ class AgentRunner:
if progress_callback is not None
else None
)
if file_edit_trackers and progress_callback is not None:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)],
)
try:
if tool is not None:
@@ -888,13 +884,10 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
if file_edit_trackers and progress_callback is not None:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, str(exc))
for file_edit_tracker in file_edit_trackers
],
[build_file_edit_error_event(file_edit_tracker, str(exc))],
)
event = {
"name": tool_call.name,
@@ -917,13 +910,10 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
if file_edit_trackers and progress_callback is not None:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, result)
for file_edit_tracker in file_edit_trackers
],
[build_file_edit_error_event(file_edit_tracker, result)],
)
event = {
"name": tool_call.name,
@@ -943,13 +933,13 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
if file_edit_trackers and progress_callback is not None:
if file_edit_tracker is not None and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)],
)
detail = "" if result is None else str(result)
-352
View File
@@ -1,352 +0,0 @@
"""Apply file edits by providing structured edit instructions."""
from __future__ import annotations
import difflib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
ObjectSchema,
StringSchema,
tool_parameters_schema,
)
@dataclass(slots=True)
class _PatchSummary:
action: str
path: str
added: int = 0
deleted: int = 0
class _PatchError(ValueError):
pass
_ABSOLUTE_WINDOWS_RE = re.compile(r"^[A-Za-z]:[\\/]")
def _validate_relative_path(path: str) -> str:
normalized = path.strip()
if not normalized:
raise _PatchError("patch path cannot be empty")
if "\0" in normalized:
raise _PatchError(f"patch path contains a null byte: {path!r}")
if normalized.startswith(("~", "/", "\\")) or _ABSOLUTE_WINDOWS_RE.match(normalized):
raise _PatchError(f"patch path must be relative: {path}")
if any(part == ".." for part in re.split(r"[\\/]+", normalized)):
raise _PatchError(f"patch path must not contain '..': {path}")
return normalized
def _lines_to_text(lines: list[str]) -> str:
if not lines:
return ""
return "\n".join(lines) + "\n"
def _text_line_count(text: str) -> int:
if not text:
return 0
return len(text.splitlines())
def _line_diff_stats(before: str, after: str) -> tuple[int, int]:
before_lines = before.replace("\r\n", "\n").splitlines()
after_lines = after.replace("\r\n", "\n").splitlines()
added = 0
deleted = 0
matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag == "equal":
continue
if tag in ("replace", "delete"):
deleted += i2 - i1
if tag in ("replace", "insert"):
added += j2 - j1
return added, deleted
def _format_summary(summary: _PatchSummary) -> str:
stats = ""
if summary.added or summary.deleted:
stats = f" (+{summary.added}/-{summary.deleted})"
return f"- {summary.action} {summary.path}{stats}"
@tool_parameters(
tool_parameters_schema(
edits=ArraySchema(
items=ObjectSchema(
path=StringSchema("Relative path to the file to edit."),
action=StringSchema(
"Operation type: replace (find and replace text), add (append new content or create file), delete (remove text).",
enum=["replace", "add", "delete"],
),
old_text=StringSchema(
"Exact text to search for in the file. Required for replace and delete.",
nullable=True,
),
new_text=StringSchema(
"Text to replace with or append. Required for replace and add.",
nullable=True,
),
required=["path", "action"],
),
description="List of edits to apply. Each edit specifies a file and the change to make.",
min_items=1,
max_items=20,
),
dry_run=BooleanSchema(
description="Validate and summarize the patch without writing files.",
default=False,
),
required=["edits"],
)
)
class ApplyPatchTool(_FsTool):
"""Apply file edits by providing structured edit instructions."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "apply_patch"
@property
def description(self) -> str:
return (
"Default tool for code edits. Supports multi-file changes in a single call. "
"Provide a list of structured edits, each specifying a file path, action (replace/add/delete), and the text to change. "
"Paths must be relative. Set dry_run=true to validate and preview without writing files. "
"Use edit_file only for small exact replacements on a single file."
)
async def execute(
self,
edits: list[dict] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
try:
if not edits:
raise _PatchError("must provide edits")
writes: dict[Path, str] = {}
deletes: set[Path] = set()
summaries: list[_PatchSummary] = []
for edit in edits:
if not isinstance(edit, dict):
raise _PatchError("each edit must be an object")
raw_path = edit.get("path")
if not isinstance(raw_path, str):
raise _PatchError("path required for edit")
path = _validate_relative_path(raw_path)
action = edit.get("action")
if not isinstance(action, str):
raise _PatchError(f"action required for edit: {path}")
source = self._resolve(path)
if action == "add":
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for add: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
exists = True
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
exists = True
else:
content = ""
exists = False
if exists:
uses_crlf = "\r\n" in content
new_norm = content.replace("\r\n", "\n") + new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
action_name = "update"
else:
new_norm = new_text.replace("\r\n", "\n")
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
writes[source] = new_norm
deletes.discard(source)
added = _text_line_count(new_norm)
deleted = 0
action_name = "add"
summaries.append(
_PatchSummary(
action=action_name, path=path, added=added, deleted=deleted
)
)
elif action == "replace":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
new_norm = (
norm_content[:pos]
+ new_text.replace("\r\n", "\n")
+ norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
elif action == "delete":
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for delete: {path}")
pending = writes.get(source)
if pending is not None:
content = pending
elif source.exists():
raw = source.read_bytes()
try:
content = raw.decode("utf-8")
except UnicodeDecodeError:
raise _PatchError(f"file is not UTF-8 text: {path}")
else:
raise _PatchError(f"file to update does not exist: {path}")
if pending is None and not source.is_file():
raise _PatchError(f"path to update is not a file: {path}")
uses_crlf = "\r\n" in content
norm_content = content.replace("\r\n", "\n")
norm_old = old_text.replace("\r\n", "\n")
pos = norm_content.find(norm_old)
if pos < 0:
raise _PatchError(f"old_text not found in {path}")
if norm_content.find(norm_old, pos + 1) >= 0:
raise _PatchError(f"old_text appears multiple times in {path}")
if norm_old == norm_content:
deletes.add(source)
writes.pop(source, None)
added, deleted = 0, _text_line_count(content)
summaries.append(
_PatchSummary(
action="delete", path=path, added=added, deleted=deleted
)
)
else:
new_norm = (
norm_content[:pos] + norm_content[pos + len(norm_old) :]
)
if new_norm and not new_norm.endswith("\n"):
new_norm += "\n"
if uses_crlf:
new_norm = new_norm.replace("\n", "\r\n")
writes[source] = new_norm
deletes.discard(source)
added, deleted = _line_diff_stats(content, new_norm)
summaries.append(
_PatchSummary(
action="update", path=path, added=added, deleted=deleted
)
)
else:
raise _PatchError(f"unknown action: {action}")
if dry_run:
return "Patch dry-run succeeded:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
backups: dict[Path, bytes | None] = {}
for path in set(writes) | deletes:
backups[path] = path.read_bytes() if path.exists() else None
try:
for path in deletes:
if path.exists():
path.unlink()
for path, content in writes.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8", newline="")
except Exception:
for path, data in backups.items():
if data is None:
if path.exists():
path.unlink()
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
raise
for path in set(writes) | deletes:
self._file_states.record_write(path)
return "Patch applied:\n" + "\n".join(
_format_summary(summary) for summary in summaries
)
except PermissionError as exc:
return f"Error: {exc}"
except _PatchError as exc:
return f"Error applying patch: {exc}"
except Exception as exc:
return f"Error applying patch: {exc}"
-127
View File
@@ -1,127 +0,0 @@
"""Controlled runner for installed CLI Apps."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import ArraySchema, BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.schema import Base
class CliAppsToolConfig(Base):
"""CLI Apps tool configuration."""
enable: bool = True
install_timeout: int = Field(default=300, ge=1, le=3600)
run_timeout: int = Field(default=60, ge=1, le=600)
catalog_ttl_seconds: int = Field(default=3600, ge=60, le=86_400)
@tool_parameters(
tool_parameters_schema(
required=["name"],
name=StringSchema("Installed CLI app registry name, for example gimp, safari, or obsidian."),
args=ArraySchema(
StringSchema("One command-line argument."),
description="Arguments to pass to the CLI entry point. Do not include the entry point itself.",
nullable=True,
),
json=BooleanSchema(
description="Whether to prepend --json when supported by the CLI.",
default=False,
nullable=True,
),
working_dir=StringSchema("Optional working directory for the CLI call.", nullable=True),
timeout=IntegerSchema(
description="Timeout in seconds for this CLI call.",
minimum=1,
maximum=600,
nullable=True,
),
)
)
class CliAppsTool(Tool):
"""Run an installed CLI-Anything or public CLI app through a controlled argv subprocess."""
config_key = "cli_apps"
_scopes = {"core", "subagent"}
@classmethod
def config_cls(cls):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
restrict_to_workspace=ctx.config.restrict_to_workspace,
runtime=CliAppsRuntimeConfig(
install_timeout=cfg.install_timeout,
run_timeout=cfg.run_timeout,
catalog_ttl_seconds=cfg.catalog_ttl_seconds,
),
)
def __init__(
self,
*,
workspace: Path,
restrict_to_workspace: bool = False,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = workspace
self.restrict_to_workspace = restrict_to_workspace
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def name(self) -> str:
return "run_cli_app"
@property
def description(self) -> str:
try:
installed = CliAppManager(workspace=self.workspace, runtime=self.runtime).installed_names()
except Exception:
installed = []
installed_note = (
f" Installed Settings CLI Apps: {', '.join(installed)}."
if installed
else " No Settings CLI Apps are currently installed."
)
return (
"Run a CLI App that the user explicitly installed in Settings or attached as @app. "
"Do not use this for ordinary system CLIs such as git, gh, python, npm, or brew; "
"unknown names are rejected. Execution uses argv, not shell."
+ installed_note
)
async def execute(
self,
name: str,
args: list[str] | None = None,
json: bool | None = False,
working_dir: str | None = None,
timeout: int | None = None,
) -> str:
manager = CliAppManager(workspace=self.workspace, runtime=self.runtime)
try:
return manager.run(
name,
args=args or [],
json_output=bool(json),
working_dir=working_dir,
timeout=timeout,
restrict_to_workspace=self.restrict_to_workspace,
)
except CliAppError as exc:
return f"Error: {exc.message}"
-591
View File
@@ -1,591 +0,0 @@
"""Session support for long-running exec workflows."""
from __future__ import annotations
import asyncio
import shutil
import time
import uuid
from contextlib import suppress
from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
DEFAULT_YIELD_MS = 1000
MAX_YIELD_MS = 30_000
DEFAULT_WAIT_FOR_MS = 10_000
MAX_WAIT_FOR_MS = 120_000
DEFAULT_MAX_OUTPUT_CHARS = 10_000
MAX_OUTPUT_CHARS = 50_000
@dataclass(slots=True)
class _SessionPoll:
output: str
done: bool
exit_code: int | None
elapsed_s: float = 0.0
timed_out: bool = False
terminated: bool = False
stdin_closed: bool = False
truncated_chars: int = 0
@dataclass(slots=True)
class ExecSessionInfo:
session_id: str
command: str
cwd: str
elapsed_s: float
idle_s: float
remaining_s: float
returncode: int | None
class _ExecSession:
def __init__(
self,
*,
session_id: str,
process: asyncio.subprocess.Process,
command: str,
cwd: str,
timeout: int,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.started_at = time.monotonic()
self.deadline = time.monotonic() + timeout
self.last_access = time.monotonic()
self._chunks: list[str] = []
self._lock = asyncio.Lock()
self._timed_out = False
self._stdout_task = asyncio.create_task(self._read_stream(process.stdout, ""))
self._stderr_task = asyncio.create_task(self._read_stream(process.stderr, "STDERR:\n"))
async def _read_stream(
self,
stream: asyncio.StreamReader | None,
prefix: str,
) -> None:
if stream is None:
return
first = True
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
if prefix and first:
text = prefix + text
first = False
async with self._lock:
self._chunks.append(text)
async def write(self, chars: str) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
try:
self.process.stdin.write(chars.encode("utf-8"))
await self.process.stdin.drain()
except (BrokenPipeError, ConnectionResetError):
return "session stdin is closed"
return None
async def close_stdin(self) -> str | None:
if self.process.returncode is not None:
return "session has already exited"
if self.process.stdin is None:
return "session stdin is not available"
self.process.stdin.close()
with suppress(BrokenPipeError, ConnectionResetError):
await self.process.stdin.wait_closed()
return None
async def poll(
self,
yield_time_ms: int,
max_output_chars: int,
*,
terminated: bool = False,
stdin_closed: bool = False,
) -> _SessionPoll:
self.last_access = time.monotonic()
if yield_time_ms > 0 and self.process.returncode is None:
await asyncio.sleep(min(yield_time_ms, MAX_YIELD_MS) / 1000)
if self.process.returncode is None and time.monotonic() >= self.deadline:
self._timed_out = True
await self.kill()
if self.process.returncode is not None:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(
asyncio.gather(self._stdout_task, self._stderr_task),
timeout=2.0,
)
async with self._lock:
output = "".join(self._chunks)
self._chunks.clear()
output, truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=output,
done=self.process.returncode is not None,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
timed_out=self._timed_out,
terminated=terminated,
stdin_closed=stdin_closed,
truncated_chars=truncated,
)
async def kill(self) -> None:
if self.process.returncode is not None:
return
self.process.kill()
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.process.wait(), timeout=5.0)
class ExecSessionManager:
def __init__(self, *, max_sessions: int = 8, idle_timeout: int = 1800) -> None:
self.max_sessions = max_sessions
self.idle_timeout = idle_timeout
self._sessions: dict[str, _ExecSession] = {}
self._lock = asyncio.Lock()
async def start(
self,
*,
command: str,
cwd: str,
env: dict[str, str],
timeout: int,
shell_program: str | None,
login: bool,
yield_time_ms: int,
max_output_chars: int,
) -> tuple[str, _SessionPoll]:
async with self._lock:
await self._cleanup_locked()
if len(self._sessions) >= self.max_sessions:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
process = await self._spawn(command, cwd, env, shell_program, login)
session_id = uuid.uuid4().hex[:12]
session = _ExecSession(
session_id=session_id,
process=process,
command=command,
cwd=cwd,
timeout=timeout,
)
self._sessions[session_id] = session
poll = await session.poll(yield_time_ms, max_output_chars)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return session_id, poll
async def write(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
) -> _SessionPoll:
async with self._lock:
await self._cleanup_locked()
session = self._sessions.get(session_id)
if session is None:
raise KeyError(session_id)
if chars:
error = await session.write(chars)
if error:
raise RuntimeError(error)
stdin_closed = False
if close_stdin:
error = await session.close_stdin()
if error:
raise RuntimeError(error)
stdin_closed = True
if terminate:
await session.kill()
poll = await session.poll(
yield_time_ms,
max_output_chars,
terminated=terminate,
stdin_closed=stdin_closed,
)
if poll.done:
async with self._lock:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
return [
ExecSessionInfo(
session_id=session_id,
command=session.command,
cwd=session.cwd,
elapsed_s=max(0.0, now - session.started_at),
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
)
for session_id, session in sorted(self._sessions.items())
]
async def _cleanup_locked(self) -> None:
now = time.monotonic()
stale = [
session_id
for session_id, session in self._sessions.items()
if now - session.last_access > self.idle_timeout
]
for session_id in stale:
session = self._sessions.pop(session_id)
await session.kill()
async def _spawn(
self,
command: str,
cwd: str,
env: dict[str, str],
shell_program: str | None,
login: bool,
) -> asyncio.subprocess.Process:
from nanobot.agent.tools import shell
if shell._IS_WINDOWS:
return await asyncio.create_subprocess_shell(
command,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
if login and shell_program.rsplit("/", 1)[-1] in {"bash", "zsh"}:
args.append("-l")
args.extend(["-c", command])
return await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
)
DEFAULT_EXEC_SESSION_MANAGER = ExecSessionManager()
def clamp_session_int(value: int | None, default: int, minimum: int, maximum: int) -> int:
if value is None:
return default
return min(max(value, minimum), maximum)
def _truncate_output(output: str, max_output_chars: int) -> tuple[str, int]:
if len(output) <= max_output_chars:
return output, 0
half = max_output_chars // 2
omitted = len(output) - max_output_chars
return (
output[:half]
+ f"\n\n... ({omitted:,} chars truncated) ...\n\n"
+ output[-half:],
omitted,
)
def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts = [poll.output] if poll.output else []
if poll.truncated_chars:
parts.append(f"(output truncated by {poll.truncated_chars:,} chars)")
if poll.timed_out:
parts.append("Error: Command timed out; session was terminated.")
if poll.terminated and not poll.timed_out:
parts.append("Session terminated.")
if poll.stdin_closed:
parts.append("Stdin closed.")
if poll.done:
parts.append(f"Exit code: {poll.exit_code}")
else:
parts.append(f"Process running. session_id: {session_id}")
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
return "\n".join(parts) if parts else "(no output yet)"
@tool_parameters(
tool_parameters_schema(
session_id=StringSchema("Session id returned by exec when yield_time_ms is used."),
chars=StringSchema(
"Bytes/text to write to stdin. Omit or pass an empty string to only poll recent output.",
nullable=True,
),
close_stdin=BooleanSchema(
description="Close stdin after writing chars. Useful for commands waiting for EOF.",
default=False,
),
terminate=BooleanSchema(
description="Terminate the running exec session.",
default=False,
),
yield_time_ms=IntegerSchema(
DEFAULT_YIELD_MS,
description="Milliseconds to wait before returning recent output (default 1000, max 30000).",
minimum=0,
maximum=MAX_YIELD_MS,
),
wait_for=StringSchema(
"Optional text to wait for in output before returning. "
"Useful for interactive commands and dev servers.",
nullable=True,
),
wait_timeout_ms=IntegerSchema(
DEFAULT_WAIT_FOR_MS,
description="Maximum milliseconds to wait for wait_for text (default 10000, max 120000).",
minimum=0,
maximum=MAX_WAIT_FOR_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Maximum output characters to return from this poll (default 10000, max 50000).",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
),
max_output_tokens=IntegerSchema(
DEFAULT_MAX_OUTPUT_CHARS,
description="Compatibility alias for max_output_chars. The current runtime uses a character budget.",
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
required=["session_id"],
)
)
class WriteStdinTool(Tool):
"""Write to or poll a running exec session."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def exclusive(self) -> bool:
return True
@property
def name(self) -> str:
return "write_stdin"
@property
def description(self) -> str:
return (
"Interact with a running exec session created by exec with "
"yield_time_ms. Use chars='' to poll without writing, chars to send "
"stdin, close_stdin=true to send EOF, or terminate=true to stop the "
"process. Use wait_for with wait_timeout_ms for dev servers, test "
"watchers, and prompts where you need to wait for expected output. "
"Do not use this to start new commands; start them with exec."
)
async def execute(
self,
session_id: str,
chars: str | None = None,
close_stdin: bool = False,
terminate: bool = False,
yield_time_ms: int | None = None,
wait_for: str | None = None,
wait_timeout_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
) -> str:
try:
if max_output_chars is None:
max_output_chars = max_output_tokens
output_limit = clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
)
if wait_for:
return await self._wait_for_output(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
wait_for=wait_for,
wait_timeout_ms=clamp_session_int(
wait_timeout_ms,
DEFAULT_WAIT_FOR_MS,
0,
MAX_WAIT_FOR_MS,
),
max_output_chars=output_limit,
)
poll = await self._manager.write(
session_id=session_id,
chars=chars,
close_stdin=close_stdin,
terminate=terminate,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=output_limit,
)
return format_session_poll(session_id, poll)
except KeyError:
return f"Error: exec session not found: {session_id}"
except Exception as exc:
return f"Error writing to exec session: {exc}"
async def _wait_for_output(
self,
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
wait_for: str,
wait_timeout_ms: int,
max_output_chars: int,
) -> str:
deadline = time.monotonic() + (wait_timeout_ms / 1000)
aggregate: list[str] = []
first = True
poll: _SessionPoll | None = None
while True:
remaining_ms = max(0, int((deadline - time.monotonic()) * 1000))
step_ms = min(500, remaining_ms)
poll = await self._manager.write(
session_id=session_id,
chars=chars if first else None,
close_stdin=close_stdin if first else False,
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
)
first = False
if poll.output:
aggregate.append(poll.output)
joined = "".join(aggregate)
if wait_for in joined:
poll.output = joined
return format_session_poll(session_id, poll)
if poll.done or remaining_ms <= 0:
poll.output = "".join(aggregate)
result = format_session_poll(session_id, poll)
if wait_for not in poll.output:
result += f"\nWait target not observed: {wait_for!r}"
return result
@tool_parameters(tool_parameters_schema())
class ListExecSessionsTool(Tool):
"""List active exec sessions."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def name(self) -> str:
return "list_exec_sessions"
@property
def description(self) -> str:
return (
"List active long-running exec sessions, including session_id, cwd, "
"elapsed time, idle time, remaining timeout, and command preview. "
"Use this to recover a session_id after context shifts before "
"polling, writing stdin, or terminating with write_stdin."
)
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
if not sessions:
return "No active exec sessions."
lines = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
command = command[:119] + "..."
status = "exited" if info.returncode is not None else "running"
lines.append(
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
f"| cwd={info.cwd} | {command}"
)
return "\n".join(lines)
except Exception as exc:
return f"Error listing exec sessions: {exc}"
+16 -100
View File
@@ -132,10 +132,6 @@ def _parse_page_range(pages: str, total: int) -> tuple[int, int]:
minimum=1,
),
pages=StringSchema("Page range for PDF files, e.g. '1-5' (default: all, max 20 pages)"),
force=BooleanSchema(
description="Bypass same-file read deduplication and return content again.",
default=False,
),
required=["path"],
)
)
@@ -158,11 +154,7 @@ class ReadFileTool(_FsTool):
"Text output format: LINE_NUM|CONTENT. "
"Images return visual content for analysis. "
"Supports PDF, DOCX, XLSX, PPTX documents. "
"Use find_files/list_dir first when the path is uncertain. "
"Read the relevant range before editing so replacements or patches "
"are based on current content. "
"Use offset and limit for large text files. "
"Use force=true to re-read content even if unchanged. "
"Reads exceeding ~128K chars are truncated."
)
@@ -170,15 +162,7 @@ class ReadFileTool(_FsTool):
def read_only(self) -> bool:
return True
async def execute(
self,
path: str | None = None,
offset: int = 1,
limit: int | None = None,
pages: str | None = None,
force: bool = False,
**kwargs: Any,
) -> Any:
async def execute(self, path: str | None = None, offset: int = 1, limit: int | None = None, pages: str | None = None, **kwargs: Any) -> Any:
try:
if not path:
return "Error reading file: Unknown path"
@@ -218,13 +202,7 @@ class ReadFileTool(_FsTool):
current_mtime = os.path.getmtime(fp)
except OSError:
current_mtime = 0.0
if (
not force
and entry
and entry.can_dedup
and entry.offset == offset
and entry.limit == limit
):
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
if current_mtime != entry.mtime:
# File was modified externally - force full read and mark as not dedupable
entry.can_dedup = False
@@ -387,10 +365,9 @@ class WriteFileTool(_FsTool):
@property
def description(self) -> str:
return (
"Create a new file or intentionally replace an entire file with "
"the provided content. Overwrites existing files and creates parent "
"directories as needed. For code changes or partial edits, prefer "
"apply_patch; use edit_file only for small exact replacements."
"Write content to a file. Overwrites if the file already exists; "
"creates parent directories as needed. "
"For partial edits, prefer edit_file instead."
)
async def execute(self, path: str | None = None, content: str | None = None, **kwargs: Any) -> str:
@@ -680,24 +657,6 @@ def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
old_text=StringSchema("The text to find and replace"),
new_text=StringSchema("The text to replace with"),
replace_all=BooleanSchema(description="Replace all occurrences (default false)"),
occurrence=IntegerSchema(
1,
description="Optional 1-based occurrence to replace when old_text appears multiple times.",
minimum=1,
nullable=True,
),
line_hint=IntegerSchema(
1,
description="Optional 1-based line hint used to choose the nearest match.",
minimum=1,
nullable=True,
),
expected_replacements=IntegerSchema(
1,
description="Optional guard for the number of replacements that must be made.",
minimum=1,
nullable=True,
),
required=["path", "old_text", "new_text"],
)
)
@@ -715,13 +674,10 @@ class EditFileTool(_FsTool):
@property
def description(self) -> str:
return (
"Perform a small, exact replacement in one file by replacing "
"old_text with new_text. Use this for narrow text substitutions "
"with old_text copied from read_file. For multi-file, structural, "
"or generated code edits, prefer apply_patch. If old_text matches "
"multiple times, provide more context or set occurrence, line_hint, "
"replace_all, and expected_replacements. Shows closest-match "
"diagnostics on failure."
"Edit a file by replacing old_text with new_text. "
"Tolerates minor whitespace/indentation differences and curly/straight quote mismatches. "
"If old_text matches multiple times, you must provide more context "
"or set replace_all=true. Shows a diff of the closest match on failure."
)
@staticmethod
@@ -732,8 +688,7 @@ class EditFileTool(_FsTool):
async def execute(
self, path: str | None = None, old_text: str | None = None,
new_text: str | None = None,
replace_all: bool = False, occurrence: int | None = None,
line_hint: int | None = None, expected_replacements: int | None = None, **kwargs: Any,
replace_all: bool = False, **kwargs: Any,
) -> str:
try:
if not path:
@@ -742,12 +697,10 @@ class EditFileTool(_FsTool):
raise ValueError("Unknown old_text")
if new_text is None:
raise ValueError("Unknown new_text")
if occurrence is not None and occurrence < 1:
return "Error: occurrence must be >= 1."
if line_hint is not None and line_hint < 1:
return "Error: line_hint must be >= 1."
if expected_replacements is not None and expected_replacements < 1:
return "Error: expected_replacements must be >= 1."
# .ipynb detection
if path.endswith(".ipynb"):
return "Error: This is a Jupyter notebook. Use the notebook_edit tool instead of edit_file."
fp = self._resolve(path)
@@ -790,28 +743,7 @@ class EditFileTool(_FsTool):
if not matches:
return self._not_found_msg(old_text, content, path)
count = len(matches)
if replace_all and occurrence is not None:
return "Error: occurrence cannot be used with replace_all=true."
if replace_all and line_hint is not None:
return "Error: line_hint cannot be used with replace_all=true."
if occurrence is not None and line_hint is not None:
return "Error: line_hint cannot be used with occurrence."
if count > 1 and not replace_all:
if occurrence is not None:
if occurrence > count:
return (
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} times."
)
elif line_hint is not None:
nearest = min(matches, key=lambda match: abs(match.line - line_hint))
distance = abs(nearest.line - line_hint)
if sum(1 for match in matches if abs(match.line - line_hint) == distance) > 1:
return (
f"Error: line_hint {line_hint} is ambiguous; "
f"old_text appears {count} times."
)
else:
line_numbers = [match.line for match in matches]
preview = ", ".join(f"line {n}" for n in line_numbers[:3])
if len(line_numbers) > 3:
@@ -819,13 +751,7 @@ class EditFileTool(_FsTool):
location_hint = f" at {preview}" if preview else ""
return (
f"Warning: old_text appears {count} times{location_hint}. "
"Provide more context, set occurrence to choose one match, "
"or set replace_all=true."
)
elif occurrence is not None and occurrence > count:
return (
f"Error: occurrence {occurrence} is out of range; "
f"old_text appears {count} time."
"Provide more context to make it unique, or set replace_all=true."
)
norm_new = new_text.replace("\r\n", "\n")
@@ -834,17 +760,7 @@ class EditFileTool(_FsTool):
if fp.suffix.lower() not in self._MARKDOWN_EXTS:
norm_new = self._strip_trailing_ws(norm_new)
if replace_all:
selected = matches
elif line_hint is not None:
selected = [min(matches, key=lambda match: abs(match.line - line_hint))]
else:
selected = [matches[occurrence - 1 if occurrence else 0]]
if expected_replacements is not None and len(selected) != expected_replacements:
return (
f"Error: expected {expected_replacements} replacements but "
f"would make {len(selected)}."
)
selected = matches if replace_all else matches[:1]
new_content = content
for match in reversed(selected):
replacement = _preserve_quote_style(norm_old, match.text, norm_new)
+9
View File
@@ -130,6 +130,12 @@ class ImageGenerationTool(Tool):
}
return cls(**kwargs)
def _missing_api_key_error(self) -> str:
cls = get_image_gen_provider(self.config.provider)
if cls and cls.missing_key_message:
return f"Error: {cls.missing_key_message}"
return f"Error: {self.config.provider} API key is not configured."
def _resolve_reference_image(self, value: str) -> str:
raw_path = Path(value).expanduser()
path = raw_path if raw_path.is_absolute() else self.workspace / raw_path
@@ -167,6 +173,9 @@ class ImageGenerationTool(Tool):
client = self._provider_client()
if client is None:
return f"Error: unsupported image generation provider '{self.config.provider}'"
provider = self._provider_config()
if not provider or not provider.api_key:
return self._missing_api_key_error()
requested = count or 1
if requested > self.config.max_images_per_turn:
+162
View File
@@ -0,0 +1,162 @@
"""NotebookEditTool — edit Jupyter .ipynb notebooks."""
from __future__ import annotations
import json
import uuid
from typing import Any
from nanobot.agent.tools.base import tool_parameters
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.filesystem import _FsTool
def _new_cell(source: str, cell_type: str = "code", generate_id: bool = False) -> dict:
cell: dict[str, Any] = {
"cell_type": cell_type,
"source": source,
"metadata": {},
}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
if generate_id:
cell["id"] = uuid.uuid4().hex[:8]
return cell
def _make_empty_notebook() -> dict:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
@tool_parameters(
tool_parameters_schema(
path=StringSchema("Path to the .ipynb notebook file"),
cell_index=IntegerSchema(0, description="0-based index of the cell to edit", minimum=0),
new_source=StringSchema("New source content for the cell"),
cell_type=StringSchema(
"Cell type: 'code' or 'markdown' (default: code)",
enum=["code", "markdown"],
),
edit_mode=StringSchema(
"Mode: 'replace' (default), 'insert' (after target), or 'delete'",
enum=["replace", "insert", "delete"],
),
required=["path", "cell_index"],
)
)
class NotebookEditTool(_FsTool):
"""Edit Jupyter notebook cells: replace, insert, or delete."""
_scopes = {"core"}
_VALID_CELL_TYPES = frozenset({"code", "markdown"})
_VALID_EDIT_MODES = frozenset({"replace", "insert", "delete"})
@property
def name(self) -> str:
return "notebook_edit"
@property
def description(self) -> str:
return (
"Edit a Jupyter notebook (.ipynb) cell. "
"Modes: replace (default) replaces cell content, "
"insert adds a new cell after the target index, "
"delete removes the cell at the index. "
"cell_index is 0-based."
)
async def execute(
self,
path: str | None = None,
cell_index: int = 0,
new_source: str = "",
cell_type: str = "code",
edit_mode: str = "replace",
**kwargs: Any,
) -> str:
try:
if not path:
return "Error: path is required"
if not path.endswith(".ipynb"):
return "Error: notebook_edit only works on .ipynb files. Use edit_file for other files."
if edit_mode not in self._VALID_EDIT_MODES:
return (
f"Error: Invalid edit_mode '{edit_mode}'. "
"Use one of: replace, insert, delete."
)
if cell_type not in self._VALID_CELL_TYPES:
return (
f"Error: Invalid cell_type '{cell_type}'. "
"Use one of: code, markdown."
)
fp = self._resolve(path)
# Create new notebook if file doesn't exist and mode is insert
if not fp.exists():
if edit_mode != "insert":
return f"Error: File not found: {path}"
nb = _make_empty_notebook()
cell = _new_cell(new_source, cell_type, generate_id=True)
nb["cells"].append(cell)
fp.parent.mkdir(parents=True, exist_ok=True)
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully created {fp} with 1 cell"
try:
nb = json.loads(fp.read_text(encoding="utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError) as e:
return f"Error: Failed to parse notebook: {e}"
cells = nb.get("cells", [])
nbformat_minor = nb.get("nbformat_minor", 0)
generate_id = nb.get("nbformat", 0) >= 4 and nbformat_minor >= 5
if edit_mode == "delete":
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells.pop(cell_index)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully deleted cell {cell_index} from {fp}"
if edit_mode == "insert":
insert_at = min(cell_index + 1, len(cells))
cell = _new_cell(new_source, cell_type, generate_id=generate_id)
cells.insert(insert_at, cell)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully inserted cell at index {insert_at} in {fp}"
# Default: replace
if cell_index < 0 or cell_index >= len(cells):
return f"Error: cell_index {cell_index} out of range (notebook has {len(cells)} cells)"
cells[cell_index]["source"] = new_source
if cell_type and cells[cell_index].get("cell_type") != cell_type:
cells[cell_index]["cell_type"] = cell_type
if cell_type == "code":
cells[cell_index].setdefault("outputs", [])
cells[cell_index].setdefault("execution_count", None)
elif "outputs" in cells[cell_index]:
del cells[cell_index]["outputs"]
cells[cell_index].pop("execution_count", None)
nb["cells"] = cells
fp.write_text(json.dumps(nb, indent=1, ensure_ascii=False), encoding="utf-8")
return f"Successfully edited cell {cell_index} in {fp}"
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error editing notebook: {e}"
+2 -169
View File
@@ -1,4 +1,4 @@
"""Search tools: file discovery and grep."""
"""Search tools: grep."""
from __future__ import annotations
@@ -12,7 +12,6 @@ from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
_DEFAULT_FILE_HEAD_LIMIT = 200
T = TypeVar("T")
_TYPE_GLOB_MAP = {
"py": ("*.py", "*.pyi"),
@@ -89,14 +88,6 @@ def _matches_type(name: str, file_type: str | None) -> bool:
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
def _matches_query(rel_path: str, query: str | None) -> bool:
if not query:
return True
haystack = rel_path.lower()
terms = [part for part in query.lower().split() if part]
return all(term in haystack for term in terms)
class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
@@ -118,163 +109,6 @@ class _SearchTool(_FsTool):
yield current / filename
class FindFilesTool(_SearchTool):
"""Find files by path fragment, glob, or type."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "find_files"
@property
def description(self) -> str:
return (
"Find files by path fragment, glob, or file type. "
"Use this before read_file when you need to locate files, and "
"prefer it over shell find/ls for ordinary workspace discovery. "
"Returns workspace-relative paths and skips common dependency/build "
"directories."
)
@property
def read_only(self) -> bool:
return True
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory or file to search in (default '.')",
},
"query": {
"type": "string",
"description": (
"Optional case-insensitive path fragment search. "
"Whitespace-separated terms must all be present."
),
},
"glob": {
"type": "string",
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
},
"type": {
"type": "string",
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
},
"include_dirs": {
"type": "boolean",
"description": "Include matching directories as well as files (default false)",
},
"sort": {
"type": "string",
"enum": ["path", "modified"],
"description": "Sort by path or most recently modified first (default path)",
},
"head_limit": {
"type": "integer",
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
"minimum": 0,
"maximum": 1000,
},
"offset": {
"type": "integer",
"description": "Skip the first N results before applying head_limit",
"minimum": 0,
"maximum": 100000,
},
},
}
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
if root.is_file():
yield root
return
if include_dirs:
yield root
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
current = Path(dirpath)
if include_dirs and current != root:
yield current
for filename in sorted(filenames):
yield current / filename
async def execute(
self,
path: str = ".",
query: str | None = None,
glob: str | None = None,
type: str | None = None,
include_dirs: bool = False,
sort: str = "path",
head_limit: int | None = None,
offset: int = 0,
**kwargs: Any,
) -> str:
try:
target = self._resolve(path or ".")
if not target.exists():
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}"
if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'"
limit = (
_DEFAULT_FILE_HEAD_LIMIT
if head_limit is None
else None if head_limit == 0 else head_limit
)
root = target if target.is_dir() else target.parent
matches: list[tuple[str, float]] = []
for candidate in self._iter_paths(target, include_dirs=include_dirs):
if candidate.is_dir() and not include_dirs:
continue
rel_path = candidate.relative_to(root).as_posix()
display_path = self._display_path(candidate, root)
name = candidate.name
if glob and not _match_glob(rel_path, name, glob):
continue
if candidate.is_file() and not _matches_type(name, type):
continue
if candidate.is_dir() and type:
continue
if not _matches_query(display_path, query):
continue
try:
mtime = candidate.stat().st_mtime
except OSError:
mtime = 0.0
suffix = "/" if candidate.is_dir() else ""
matches.append((display_path + suffix, mtime))
if sort == "modified":
matches.sort(key=lambda item: (-item[1], item[0]))
else:
matches.sort(key=lambda item: item[0])
paths = [item[0] for item in matches]
paged, truncated = _paginate(paths, limit, offset)
if not paged:
return "No files found"
result = "\n".join(paged)
note = _pagination_note(limit, offset, truncated)
if note:
result += "\n\n" + note
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error finding files: {e}"
class GrepTool(_SearchTool):
"""Search file contents using a regex-like pattern."""
_scopes = {"core", "subagent"}
@@ -291,8 +125,7 @@ class GrepTool(_SearchTool):
return (
"Search file contents with a regex pattern. "
"Default output_mode is files_with_matches (file paths only); "
"use content mode for matching lines with context. Prefer this "
"over shell grep for ordinary workspace searches. "
"use content mode for matching lines with context. "
"Skips binary and files >2 MB. Supports glob/type filtering."
)
+49 -219
View File
@@ -8,7 +8,6 @@ import re
import shutil
import sys
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@@ -16,17 +15,8 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, tool_parameters
from nanobot.agent.tools.exec_session import (
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
DEFAULT_EXEC_SESSION_MANAGER,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
clamp_session_int,
format_session_poll,
)
from nanobot.agent.tools.sandbox import wrap_command
from nanobot.agent.tools.schema import BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.agent.tools.schema import IntegerSchema, StringSchema, tool_parameters_schema
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
@@ -54,22 +44,10 @@ class ExecToolConfig(Base):
deny_patterns: list[str] = Field(default_factory=list)
@dataclass(slots=True)
class _PreparedCommand:
command: str
cwd: str
env: dict[str, str]
timeout: int
shell_program: str | None
login: bool
@tool_parameters(
tool_parameters_schema(
command=StringSchema("The shell command to execute"),
cmd=StringSchema("Compatibility alias for command"),
working_dir=StringSchema("Optional working directory for the command"),
workdir=StringSchema("Compatibility alias for working_dir"),
timeout=IntegerSchema(
60,
description=(
@@ -79,44 +57,7 @@ class _PreparedCommand:
minimum=1,
maximum=600,
),
shell=StringSchema(
"Optional shell binary to launch. On Unix, supports sh, bash, or zsh.",
nullable=True,
),
login=BooleanSchema(
description="Whether to run bash/zsh with login shell semantics (default true).",
default=True,
nullable=True,
),
yield_time_ms=IntegerSchema(
description=(
"Optional milliseconds to wait before returning output. "
"When set, a still-running command returns a session_id that "
"can be polled or written to with write_stdin. Omit this field "
"to keep one-shot exec behavior."
),
minimum=0,
maximum=MAX_YIELD_MS,
nullable=True,
),
max_output_chars=IntegerSchema(
description=(
"Maximum output characters to return when yield_time_ms is used "
"(default 10000, max 50000)."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
max_output_tokens=IntegerSchema(
description=(
"Compatibility alias for max_output_chars. The current runtime "
"uses a character budget."
),
minimum=1000,
maximum=MAX_OUTPUT_CHARS,
nullable=True,
),
required=["command"],
)
)
class ExecTool(Tool):
@@ -157,7 +98,6 @@ class ExecTool(Tool):
sandbox: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
@@ -185,7 +125,6 @@ class ExecTool(Tool):
self.restrict_to_workspace = restrict_to_workspace
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@property
def name(self) -> str:
@@ -211,15 +150,10 @@ class ExecTool(Tool):
def description(self) -> str:
return (
"Execute a shell command and return its output. "
"Use this for tests, builds, package commands, git commands, and "
"other process execution. Prefer read_file/find_files/grep for "
"inspection and apply_patch/write_file/edit_file for file changes "
"instead of cat, shell find/grep, echo, or sed. "
"Prefer read_file/write_file/edit_file over cat/echo/sed, "
"and grep/glob over shell find/grep. "
"Use -y or --yes flags to avoid interactive prompts. "
"For long-running or interactive commands, pass yield_time_ms; "
"if the command keeps running, exec returns a session_id that can "
"be polled or written to with write_stdin. Output is truncated at "
"10 000 chars; timeout defaults to 60s."
"Output is truncated at 10 000 chars; timeout defaults to 60s."
)
@property
@@ -227,111 +161,9 @@ class ExecTool(Tool):
return True
async def execute(
self, command: str | None = None, cmd: str | None = None,
working_dir: str | None = None, workdir: str | None = None,
timeout: int | None = None, shell: str | None = None,
login: bool | None = None, yield_time_ms: int | None = None,
max_output_chars: int | None = None,
max_output_tokens: int | None = None,
**kwargs: Any,
self, command: str, working_dir: str | None = None,
timeout: int | None = None, **kwargs: Any,
) -> str:
command = command or cmd
working_dir = working_dir or workdir
if not command:
return "Error: Missing command. Provide command or cmd."
if max_output_chars is None:
max_output_chars = max_output_tokens
prepared = self._prepare_command(command, working_dir, timeout, shell, login)
if isinstance(prepared, str):
return prepared
if yield_time_ms is not None:
return await self._execute_session(prepared, yield_time_ms, max_output_chars)
try:
process = await self._spawn(
prepared.command,
prepared.cwd,
prepared.env,
prepared.shell_program,
prepared.login,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=prepared.timeout,
)
except asyncio.TimeoutError:
await self._kill_process(process)
return f"Error: Command timed out after {prepared.timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = clamp_session_int(max_output_chars, self._MAX_OUTPUT, 1000, MAX_OUTPUT_CHARS)
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
async def _execute_session(
self,
prepared: _PreparedCommand,
yield_time_ms: int | None,
max_output_chars: int | None,
) -> str:
try:
session_id, poll = await self._session_manager.start(
command=prepared.command,
cwd=prepared.cwd,
env=prepared.env,
timeout=prepared.timeout,
shell_program=prepared.shell_program,
login=prepared.login,
yield_time_ms=clamp_session_int(yield_time_ms, DEFAULT_YIELD_MS, 0, MAX_YIELD_MS),
max_output_chars=clamp_session_int(
max_output_chars,
DEFAULT_MAX_OUTPUT_CHARS,
1000,
MAX_OUTPUT_CHARS,
),
)
return format_session_poll(session_id, poll)
except Exception as exc:
return f"Error executing command: {exc}"
def _prepare_command(
self,
command: str,
working_dir: str | None = None,
timeout: int | None = None,
shell: str | None = None,
login: bool | None = None,
) -> _PreparedCommand | str:
cwd = working_dir or self.working_dir or os.getcwd()
# Prevent an LLM-supplied working_dir from escaping the configured
@@ -379,24 +211,52 @@ class ExecTool(Tool):
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
shell_program, shell_error = self._resolve_shell(shell)
if shell_error:
return shell_error
try:
process = await self._spawn(command, cwd, env)
return _PreparedCommand(
command=command,
cwd=cwd,
env=env,
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=effective_timeout,
shell_program=shell_program,
login=True if login is None else login,
)
except asyncio.TimeoutError:
await self._kill_process(process)
return f"Error: Command timed out after {effective_timeout} seconds"
except asyncio.CancelledError:
await self._kill_process(process)
raise
output_parts = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
stderr_text = stderr.decode("utf-8", errors="replace")
if stderr_text.strip():
output_parts.append(f"STDERR:\n{stderr_text}")
output_parts.append(f"\nExit code: {process.returncode}")
result = "\n".join(output_parts) if output_parts else "(no output)"
max_len = self._MAX_OUTPUT
if len(result) > max_len:
half = max_len // 2
result = (
result[:half]
+ f"\n\n... ({len(result) - max_len:,} chars truncated) ...\n\n"
+ result[-half:]
)
return result
except Exception as e:
return f"Error executing command: {str(e)}"
@staticmethod
async def _spawn(
command: str, cwd: str, env: dict[str, str],
shell_program: str | None = None,
login: bool = True,
) -> asyncio.subprocess.Process:
"""Launch *command* in a platform-appropriate shell."""
if _IS_WINDOWS:
@@ -412,14 +272,9 @@ class ExecTool(Tool):
cwd=cwd,
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l")
args.extend(["-c", command])
bash = shutil.which("bash") or "/bin/bash"
return await asyncio.create_subprocess_exec(
*args,
bash, "-l", "-c", command,
stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -427,31 +282,6 @@ class ExecTool(Tool):
env=env,
)
@staticmethod
def _resolve_shell(shell: str | None) -> tuple[str | None, str | None]:
if not shell:
return None, None
if _IS_WINDOWS:
return None, "Error: shell parameter is not supported on Windows"
if "\0" in shell or "\n" in shell or "\r" in shell:
return None, "Error: shell contains invalid characters"
allowed = {"sh", "bash", "zsh"}
path = Path(shell).expanduser()
if path.is_absolute():
if path.name not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
if not path.is_file() or not os.access(path, os.X_OK):
return None, f"Error: shell is not executable: {shell}"
return str(path), None
if "/" in shell or "\\" in shell:
return None, "Error: shell must be a shell name or absolute path"
if shell not in allowed:
return None, f"Error: unsupported shell {shell!r}. Allowed: bash, sh, zsh"
resolved = shutil.which(shell)
if not resolved:
return None, f"Error: shell not found: {shell}"
return resolved, None
@staticmethod
async def _kill_process(process: asyncio.subprocess.Process) -> None:
"""Kill a subprocess and reap it to prevent zombies."""
@@ -588,7 +418,7 @@ class ExecTool(Tool):
# Windows: match drive-root paths like `C:\` as well as `C:\path\to\file`, and UNC paths like `\\server\share`
# NOTE: `*` is required so `C:\` (nothing after the slash) is still extracted.
win_paths = re.findall(
r"(?<![A-Za-z])(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
r"(?:[A-Za-z]:[^\s\"'|><;]*|\\\\[^\s\"'|><;]+(?:\\[^\s\"'|><;]+)*)",
command
)
posix_paths = re.findall(r"(?:^|[\s|>'\"])(/[^\s\"'>;|<]+)", command) # POSIX: /absolute only
+18 -99
View File
@@ -8,7 +8,7 @@ import json
import os
import re
from typing import Any, Callable
from urllib.parse import quote, urljoin, urlparse
from urllib.parse import quote, urlparse
import httpx
from loguru import logger
@@ -78,82 +78,9 @@ def _validate_url(url: str) -> tuple[bool, str]:
def _validate_url_safe(url: str) -> tuple[bool, str]:
"""Validate URL with SSRF protection: scheme, domain, and resolved IP check."""
from nanobot.security.network import validate_url_target
return validate_url_target(url)
async def _get_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, str | None]:
"""GET a URL while validating every redirect target before requesting it."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, f"Redirect blocked: {error_msg}"
response = await client.get(current_url, headers=headers, follow_redirects=False)
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, None
location = response.headers.get("location")
if not location:
return response, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await response.aclose()
return None, f"Redirect blocked: {error_msg}"
await response.aclose()
current_url = next_url
return None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
async def _stream_with_safe_redirects(
client: httpx.AsyncClient,
url: str,
headers: dict[str, str] | None = None,
) -> tuple[httpx.Response | None, Any | None, str | None]:
"""Open a streamed response while validating every redirect target first."""
current_url = url
for _ in range(MAX_REDIRECTS + 1):
is_valid, error_msg = _validate_url_safe(current_url)
if not is_valid:
return None, None, f"Redirect blocked: {error_msg}"
stream = client.stream(
"GET",
current_url,
headers=headers,
follow_redirects=False,
)
response = await stream.__aenter__()
is_redirect = 300 <= response.status_code < 400
if not is_redirect:
return response, stream, None
location = response.headers.get("location")
if not location:
return response, stream, None
next_url = urljoin(str(response.url), location)
is_valid, error_msg = _validate_url_safe(next_url)
if not is_valid:
await stream.__aexit__(None, None, None)
return None, None, f"Redirect blocked: {error_msg}"
await stream.__aexit__(None, None, None)
current_url = next_url
return None, None, f"Too many redirects: exceeded limit of {MAX_REDIRECTS}"
def _format_results(query: str, items: list[dict[str, Any]], n: int) -> str:
"""Format provider results into shared plaintext output."""
if not items:
@@ -561,26 +488,19 @@ class WebFetchTool(Tool):
# Detect and fetch images directly to avoid Jina's textual image captioning
try:
async with httpx.AsyncClient(proxy=self.proxy, timeout=15.0) as client:
r, stream, redirect_error = await _stream_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
async with httpx.AsyncClient(proxy=self.proxy, follow_redirects=True, max_redirects=MAX_REDIRECTS, timeout=15.0) as client:
async with client.stream("GET", url, headers={"User-Agent": self.user_agent}) as r:
from nanobot.security.network import validate_resolved_url
redir_ok, redir_err = validate_resolved_url(str(r.url))
if not redir_ok:
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
try:
ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"):
r.raise_for_status()
raw = await r.aread()
return build_image_content_blocks(raw, ctype, url, f"(Image fetched from: {url})")
finally:
if stream is not None:
await stream.__aexit__(None, None, None)
except Exception as e:
logger.debug("Pre-fetch image detection failed for {}: {}", url, e)
@@ -629,22 +549,23 @@ class WebFetchTool(Tool):
async def _fetch_readability(self, url: str, extract_mode: str, max_chars: int) -> Any:
"""Local fallback using readability-lxml."""
from readability import Document
try:
async with httpx.AsyncClient(
follow_redirects=True,
max_redirects=MAX_REDIRECTS,
timeout=30.0,
proxy=self.proxy,
) as client:
r, redirect_error = await _get_with_safe_redirects(
client,
url,
headers={"User-Agent": self.user_agent},
)
if redirect_error:
return json.dumps({"error": redirect_error, "url": url}, ensure_ascii=False)
if r is None:
return json.dumps({"error": "Fetch failed", "url": url}, ensure_ascii=False)
r = await client.get(url, headers={"User-Agent": self.user_agent})
r.raise_for_status()
from nanobot.security.network import validate_resolved_url
redir_ok, redir_err = validate_resolved_url(str(r.url))
if not redir_ok:
return json.dumps({"error": f"Redirect blocked: {redir_err}", "url": url}, ensure_ascii=False)
ctype = r.headers.get("content-type", "")
if ctype.startswith("image/"):
return build_image_content_blocks(r.content, ctype, url, f"(Image fetched from: {url})")
@@ -652,8 +573,6 @@ class WebFetchTool(Tool):
if "application/json" in ctype:
text, extractor = json.dumps(r.json(), indent=2, ensure_ascii=False), "json"
elif "text/html" in ctype or r.text[:256].lower().startswith(("<!doctype", "<html")):
from readability import Document
doc = Document(r.text)
content = self._to_markdown(doc.summary()) if extract_mode == "markdown" else _strip_tags(doc.summary())
text = f"# {doc.title()}\n\n{content}" if doc.title() else content
-1
View File
@@ -110,7 +110,6 @@ class ChannelManager:
static_path = _default_webui_dist()
if static_path is not None:
kwargs["static_dist_path"] = static_path
kwargs["workspace_path"] = self.config.workspace_path
if self._webui_runtime_model_name is not None:
kwargs["runtime_model_name"] = self._webui_runtime_model_name
channel = cls(section, self.bus, **kwargs)
File diff suppressed because it is too large Load Diff
+4 -140
View File
@@ -34,7 +34,7 @@ from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.command.builtin import builtin_command_palette
from nanobot.config.paths import get_media_dir, get_workspace_path
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from nanobot.session.goal_state import goal_state_ws_blob
from nanobot.session.webui_turns import websocket_turn_wall_started_at
@@ -52,11 +52,6 @@ from nanobot.webui.settings_api import (
update_provider_settings,
update_web_search_settings,
)
from nanobot.webui.cli_apps_api import (
cli_apps_action,
cli_apps_payload,
normalize_cli_app_mentions,
)
from nanobot.webui.sidebar_state import (
read_webui_sidebar_state,
write_webui_sidebar_state,
@@ -425,16 +420,6 @@ _MEDIA_ALLOWED_MIMES: frozenset[str] = frozenset({
"video/webm",
"video/quicktime",
})
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
)
_INLINE_MARKDOWN_IMAGE_EXTS: frozenset[str] = frozenset({
".png",
".jpg",
".jpeg",
".webp",
".gif",
})
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
@@ -464,7 +449,6 @@ class WebSocketChannel(BaseChannel):
*,
session_manager: "SessionManager | None" = None,
static_dist_path: Path | None = None,
workspace_path: Path | None = None,
runtime_model_name: Callable[[], str | None] | None = None,
):
if isinstance(config, dict):
@@ -477,10 +461,6 @@ class WebSocketChannel(BaseChannel):
self._conn_chats: dict[Any, set[str]] = {}
# connection -> default chat_id for legacy frames that omit routing.
self._conn_default: dict[Any, str] = {}
# Chat IDs that opted into WebUI-specific rendering by sending a typed
# envelope with ``webui: true``. Raw WebSocket clients keep the legacy
# wire shape.
self._webui_chats: set[str] = set()
# Single-use tokens consumed at WebSocket handshake.
self._issued_tokens: dict[str, float] = {}
# Multi-use tokens for HTTP routes served beside WS; checked but not consumed.
@@ -491,14 +471,8 @@ class WebSocketChannel(BaseChannel):
self._static_dist_path: Path | None = (
static_dist_path.resolve() if static_dist_path is not None else None
)
self._workspace_path = (
Path(workspace_path).expanduser()
if workspace_path is not None
else get_workspace_path()
).resolve(strict=False)
self._runtime_model_name = runtime_model_name
self._settings_restart_sections: set[str] = set()
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
# Process-local secret used to HMAC-sign media URLs. The signed URL is
# the capability — anyone who holds a valid URL can fetch that one
# file, nothing else. The secret regenerates on restart so links
@@ -679,21 +653,6 @@ class WebSocketChannel(BaseChannel):
if got == "/api/settings/image-generation/update":
return self._handle_settings_image_generation_update(request)
if got == "/api/settings/cli-apps":
return self._handle_settings_cli_apps(request)
if got == "/api/settings/cli-apps/install":
return await self._handle_settings_cli_apps_action(request, "install")
if got == "/api/settings/cli-apps/update":
return await self._handle_settings_cli_apps_action(request, "update")
if got == "/api/settings/cli-apps/uninstall":
return await self._handle_settings_cli_apps_action(request, "uninstall")
if got == "/api/settings/cli-apps/test":
return await self._handle_settings_cli_apps_action(request, "test")
m = re.match(r"^/api/sessions/([^/]+)/messages$", got)
if m:
return self._handle_session_messages(request, m.group(1))
@@ -915,32 +874,6 @@ class WebSocketChannel(BaseChannel):
return _http_error(e.status, e.message)
return _http_json_response(self._with_settings_restart_state(payload, section="image"))
def _handle_settings_cli_apps(self, request: WsRequest) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
try:
payload = cli_apps_payload()
except Exception:
self.logger.exception("failed to load CLI Apps payload")
return _http_error(500, "failed to load CLI Apps")
return _http_json_response(payload)
async def _handle_settings_cli_apps_action(self, request: WsRequest, action: str) -> Response:
if not self._check_api_token(request):
return _http_error(401, "Unauthorized")
query = _parse_query(request.path)
try:
payload = await asyncio.to_thread(cli_apps_action, action, query)
except WebUISettingsError as e:
return _http_error(e.status, e.message)
except Exception as e:
status = getattr(e, "status", 500)
message = getattr(e, "message", str(e))
if status >= 500:
self.logger.exception("CLI Apps action '{}' failed", action)
return _http_error(status, message)
return _http_json_response(payload)
@staticmethod
def _is_websocket_channel_session_key(key: str) -> bool:
"""True when *key* is a ``websocket:…`` session exposed on this HTTP surface."""
@@ -982,7 +915,6 @@ class WebSocketChannel(BaseChannel):
data = build_webui_thread_response(
decoded_key,
augment_user_media=self._augment_transcript_user_media,
augment_assistant_text=self._rewrite_local_markdown_images,
)
if data is None:
return _http_error(404, "webui thread not found")
@@ -1029,9 +961,6 @@ class WebSocketChannel(BaseChannel):
}
if media:
user_obj["media_paths"] = list(media)
cli_apps = meta.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
user_obj["cli_apps"] = cli_apps
self._try_append_webui_transcript(chat_id, user_obj)
await super()._handle_message(
sender_id,
@@ -1121,46 +1050,6 @@ class WebSocketChannel(BaseChannel):
return None
return {"url": signed, "name": path.name}
def _markdown_image_url_for_local_path(self, raw_url: str) -> str | None:
url = raw_url.strip()
if url.startswith("<") and url.endswith(">"):
url = url[1:-1].strip()
if not url or url.startswith(("/api/media/", "#")):
return None
parsed = urlparse(url)
if parsed.scheme or parsed.netloc:
return None
if parsed.query or parsed.fragment:
return None
path_text = unquote(url)
if Path(path_text).suffix.lower() not in _INLINE_MARKDOWN_IMAGE_EXTS:
return None
candidate = Path(path_text).expanduser()
if not candidate.is_absolute():
candidate = self._workspace_path / candidate
try:
resolved = candidate.resolve(strict=False)
resolved.relative_to(self._workspace_path)
except (OSError, ValueError):
return None
if not resolved.is_file():
return None
signed = self._sign_or_stage_media_path(resolved)
return signed["url"] if signed else None
def _rewrite_local_markdown_images(self, text: str) -> str:
if "![" not in text:
return text
def replace(match: re.Match[str]) -> str:
signed_url = self._markdown_image_url_for_local_path(match.group(2))
if not signed_url:
return match.group(0)
title = match.group(3) or ""
return f"![{match.group(1)}]({signed_url}{title})"
return _MARKDOWN_LOCAL_IMAGE_RE.sub(replace, text)
def _handle_media_fetch(self, sig: str, payload: str) -> Response:
"""Serve a single media file previously signed via
:meth:`_sign_media_path`. Validates the signature, decodes the
@@ -1532,10 +1421,6 @@ class WebSocketChannel(BaseChannel):
metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)}
if envelope.get("webui") is True:
metadata["webui"] = True
self._webui_chats.add(cid)
cli_apps = normalize_cli_app_mentions(envelope.get("cli_apps"))
if cli_apps:
metadata["cli_apps"] = cli_apps
image_generation = envelope.get("image_generation")
if isinstance(image_generation, dict) and image_generation.get("enabled") is True:
aspect_ratio = image_generation.get("aspect_ratio")
@@ -1569,7 +1454,6 @@ class WebSocketChannel(BaseChannel):
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
self._webui_chats.clear()
self._issued_tokens.clear()
self._api_tokens.clear()
@@ -1648,12 +1532,10 @@ class WebSocketChannel(BaseChannel):
await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
should_rewrite_images = msg.chat_id in self._webui_chats
wire_text = self._rewrite_local_markdown_images(text) if should_rewrite_images else text
payload: dict[str, Any] = {
"event": "message",
"chat_id": msg.chat_id,
"text": wire_text,
"text": text,
}
if msg.media:
payload["media"] = msg.media
@@ -1681,9 +1563,7 @@ class WebSocketChannel(BaseChannel):
payload["kind"] = "tool_hint"
elif msg.metadata.get("_progress"):
payload["kind"] = "progress"
transcript_payload = dict(payload)
transcript_payload["text"] = text
self._try_append_webui_transcript(msg.chat_id, transcript_payload)
self._try_append_webui_transcript(msg.chat_id, payload)
raw = json.dumps(payload, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" ")
@@ -1748,33 +1628,17 @@ class WebSocketChannel(BaseChannel):
if not conns:
return
meta = metadata or {}
stream_key = (chat_id, str(meta.get("_stream_id") or ""))
should_rewrite_images = chat_id in self._webui_chats
transcript_body: dict[str, Any] | None = None
if meta.get("_stream_end"):
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
if should_rewrite_images:
buffered = self._stream_text_buffers.pop(stream_key, [])
if delta:
buffered.append(delta)
full_text = "".join(buffered)
rewritten = self._rewrite_local_markdown_images(full_text)
if rewritten != full_text or delta:
body["text"] = rewritten
transcript_body = {**body, "text": full_text}
else:
body = {
"event": "delta",
"chat_id": chat_id,
"text": delta,
}
if should_rewrite_images:
self._stream_text_buffers.setdefault(stream_key, []).append(delta)
if meta.get("_stream_id") is not None:
body["stream_id"] = meta["_stream_id"]
if transcript_body is not None:
transcript_body["stream_id"] = meta["_stream_id"]
self._try_append_webui_transcript(chat_id, transcript_body or body)
self._try_append_webui_transcript(chat_id, body)
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" stream ")
+6 -163
View File
@@ -79,12 +79,6 @@ BASE_INFO: dict[str, str] = {"channel_version": WEIXIN_CHANNEL_VERSION}
ERRCODE_SESSION_EXPIRED = -14
SESSION_PAUSE_DURATION_S = 60 * 60
# iLink context_token is observed to expire server-side after ~90-160s of
# agent inactivity (openclaw/openclaw#61174). Proactively refresh before
# sending if the cached token is older than this threshold.
CONTEXT_TOKEN_MAX_AGE_S = 60
# Retry constants (matching the reference plugin's monitor.ts)
MAX_CONSECUTIVE_FAILURES = 3
BACKOFF_DELAY_S = 30
@@ -165,8 +159,6 @@ class WeixinChannel(BaseChannel):
self._session_pause_until: float = 0.0
self._typing_tasks: dict[str, asyncio.Task] = {}
self._typing_tickets: dict[str, dict[str, Any]] = {}
self._context_token_at: dict[str, float] = {}
self._pending_tool_hints: dict[str, list[str]] = {}
# ------------------------------------------------------------------
# State persistence
@@ -494,7 +486,6 @@ class WeixinChannel(BaseChannel):
except Exception:
if not self._running:
break
self.logger.exception("WeChat poll loop error")
consecutive_failures += 1
if consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
consecutive_failures = 0
@@ -504,7 +495,6 @@ class WeixinChannel(BaseChannel):
async def stop(self) -> None:
self._running = False
self._pending_tool_hints.clear()
if self._poll_task and not self._poll_task.done():
self._poll_task.cancel()
for chat_id in list(self._typing_tasks):
@@ -555,7 +545,6 @@ class WeixinChannel(BaseChannel):
# Check for API-level errors (monitor.ts checks both ret and errcode)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
is_error = (ret is not None and ret != 0) or (errcode is not None and errcode != 0)
if is_error:
@@ -586,10 +575,8 @@ class WeixinChannel(BaseChannel):
# Process messages (WeixinMessage[] from types.ts)
msgs: list[dict] = data.get("msgs", []) or []
for msg in msgs:
try:
with suppress(Exception):
await self._process_message(msg)
except Exception:
self.logger.exception("Failed to process WeChat message")
# ------------------------------------------------------------------
# Inbound message processing (matches inbound.ts + process-message.ts)
@@ -623,7 +610,6 @@ class WeixinChannel(BaseChannel):
ctx_token = msg.get("context_token", "")
if ctx_token:
self._context_tokens[from_user_id] = ctx_token
self._context_token_at[from_user_id] = time.time()
self._save_state()
# Parse item_list (WeixinMessage.item_list — types.ts:161)
@@ -929,99 +915,6 @@ class WeixinChannel(BaseChannel):
}
return ""
async def _refresh_context_token_if_stale(
self, chat_id: str, context_token: str
) -> str:
"""Return a fresh context_token if the cached one is too old.
iLink context_token expires server-side after a short idle period
(empirically ~90s). Proactively refreshing before sending prevents
silent message loss on long agent turns or cron pushes.
"""
if not context_token:
return context_token
now = time.time()
cached_at = self._context_token_at.get(chat_id, 0)
age = now - cached_at
if age < CONTEXT_TOKEN_MAX_AGE_S:
return context_token
self.logger.debug(
"WeChat context_token for {} is {:.0f}s old; refreshing via getconfig",
chat_id,
age,
)
body: dict[str, Any] = {
"ilink_user_id": chat_id,
"context_token": context_token,
"base_info": BASE_INFO,
}
try:
data = await self._api_post("ilink/bot/getconfig", body)
except Exception as e:
self.logger.warning("WeChat getconfig failed for {}: {}", chat_id, e)
return context_token
if data.get("ret", 0) != 0:
self.logger.warning(
"WeChat getconfig returned ret={} for {}: {}",
data.get("ret"),
chat_id,
data.get("errmsg", ""),
)
return context_token
new_token = str(data.get("context_token", "") or "")
if new_token and new_token != context_token:
self.logger.info(
"WeChat context_token refreshed for {} (age {:.0f}s -> fresh)",
chat_id,
age,
)
self._context_tokens[chat_id] = new_token
self._context_token_at[chat_id] = now
self._save_state()
return new_token
return context_token
async def _flush_tool_hints(self, chat_id: str) -> None:
"""Send any buffered tool hints for *chat_id* as a single message.
Tool hints are coalesced to reduce message count and avoid hitting the
WeChat iLink rate limit (~7 msgs / 5 min). Failures are logged but
not raised so that the main message send is never blocked.
"""
hints = self._pending_tool_hints.pop(chat_id, None)
if not hints:
return
self.logger.info(
"Flushing {} buffered tool hint(s) for {}",
len(hints),
chat_id,
)
ctx_token = self._context_tokens.get(chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(chat_id, ctx_token)
if not ctx_token:
self.logger.warning(
"Dropped {} buffered tool hint(s) for {}: no context_token",
len(hints),
chat_id,
)
return
try:
await self._send_text(chat_id, "\n\n".join(hints), ctx_token)
except Exception:
self.logger.exception(
"Failed to flush buffered tool hints for {}", chat_id
)
async def _send_typing(self, user_id: str, typing_ticket: str, status: int) -> None:
"""Best-effort sendtyping wrapper."""
if not typing_ticket:
@@ -1051,47 +944,11 @@ class WeixinChannel(BaseChannel):
self._assert_session_active()
is_progress = bool((msg.metadata or {}).get("_progress", False))
# Buffer tool hints to coalesce consecutive ones and avoid burning
# WeChat iLink rate-limit quota (~7 msgs / 5 min).
if is_progress and (msg.metadata or {}).get("_tool_hint"):
if not self.send_tool_hints:
return
self._pending_tool_hints.setdefault(msg.chat_id, []).append(msg.content)
self.logger.debug(
"Buffered tool hint for {} (count={})",
msg.chat_id,
len(self._pending_tool_hints[msg.chat_id]),
)
return
# Reasoning deltas are invisible in WeChat (there is no reasoning
# UI). Skip them entirely — do not send and do not flush buffer.
if is_progress and (msg.metadata or {}).get("_reasoning_delta"):
self.logger.debug(
"Dropped invisible reasoning delta for {}", msg.chat_id
)
return
content = msg.content.strip()
# Empty progress messages (e.g. after_iteration tool_events) must
# NOT act as separators — they have no visible content.
if is_progress and not content and not (msg.media or []):
self.logger.debug(
"Skipped empty progress message for {} (no visible content)",
msg.chat_id,
)
return
# Flush buffered hints before sending any visible message.
await self._flush_tool_hints(msg.chat_id)
if not is_progress:
await self._stop_typing(msg.chat_id, clear_remote=True)
content = msg.content.strip()
ctx_token = self._context_tokens.get(msg.chat_id, "")
ctx_token = await self._refresh_context_token_if_stale(msg.chat_id, ctx_token)
if not ctx_token:
raise RuntimeError(
f"WeChat context_token missing for chat_id={msg.chat_id}, cannot send"
@@ -1180,18 +1037,6 @@ class WeixinChannel(BaseChannel):
with suppress(Exception):
await self._send_typing(msg.chat_id, typing_ticket, TYPING_STATUS_CANCEL)
async def send_delta(
self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None
) -> None:
"""Weixin iLink does not support native streaming deltas.
We only hook ``_stream_end`` so buffered tool hints are flushed even
when the final answer carries the ``_streamed`` flag and bypasses
:meth:`send`.
"""
if metadata and metadata.get("_stream_end"):
await self._flush_tool_hints(chat_id)
async def _start_typing(self, chat_id: str, context_token: str = "") -> None:
"""Start typing indicator immediately when a message is received."""
if not self._client or not self._token or not chat_id:
@@ -1275,11 +1120,10 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
if errcode and errcode != 0:
raise RuntimeError(
f"WeChat send text error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
f"WeChat send text error (code {errcode}): {data.get('errmsg', '')}"
)
async def _send_media_file(
@@ -1426,11 +1270,10 @@ class WeixinChannel(BaseChannel):
}
data = await self._api_post("ilink/bot/sendmessage", body)
ret = data.get("ret", 0)
errcode = data.get("errcode", 0)
if (ret is not None and ret != 0) or (errcode is not None and errcode != 0):
if errcode and errcode != 0:
raise RuntimeError(
f"WeChat send media error (ret={ret}, errcode={errcode}): {data.get('errmsg', '')}"
f"WeChat send media error (code {errcode}): {data.get('errmsg', '')}"
)
+163 -3
View File
@@ -1,12 +1,14 @@
"""CLI commands for nanobot."""
import asyncio
import json
import os
import select
import signal
import sys
from collections.abc import Callable
from contextlib import nullcontext, suppress
from inspect import signature
from pathlib import Path
from typing import Any
@@ -1527,6 +1529,106 @@ def status():
console.print(f"{spec.label}: {'[green]✓[/green]' if has_key else '[dim]not set[/dim]'}")
# ============================================================================
# Config Commands
# ============================================================================
config_app = typer.Typer(help="Manage configuration")
app.add_typer(config_app, name="config")
@config_app.command("set")
def config_set(
path: str = typer.Argument(..., help="Dot path, e.g. agents.defaults.model"),
value: str = typer.Argument(..., help="Value. Use null/true/false or JSON for structured values."),
config_path: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
):
"""Set one config value by dot path."""
from pydantic import ValidationError
from nanobot.config.loader import get_config_path, load_config, save_config, set_config_path
from nanobot.config.schema import Config
resolved_path = Path(config_path).expanduser().resolve() if config_path else get_config_path()
if config_path:
set_config_path(resolved_path)
config = load_config(resolved_path)
parsed = _parse_config_cli_value(value)
try:
_set_config_cli_value(config, path, parsed)
validated = Config.model_validate(config.model_dump(mode="json", by_alias=True))
except (AttributeError, KeyError, TypeError, ValueError, ValidationError) as exc:
console.print(f"[red]Could not set config value:[/red] {exc}")
raise typer.Exit(1)
save_config(validated, resolved_path)
console.print(f"[green]✓[/green] Set [cyan]{path}[/cyan] = [bold]{value}[/bold]")
console.print(f"[dim]Config: {resolved_path}[/dim]")
if path in {"agents.defaults.provider", "agents.defaults.model"} and validated.agents.defaults.model_preset:
console.print(
"[yellow]! agents.defaults.model_preset is set and may override this. "
"Clear it with: nanobot config set agents.defaults.model_preset null[/yellow]"
)
def _parse_config_cli_value(raw: str) -> Any:
lowered = raw.strip().lower()
if lowered == "null":
return None
if lowered == "true":
return True
if lowered == "false":
return False
with suppress(Exception):
return json.loads(raw)
return raw
def _resolve_config_field(obj: Any, key: str) -> str:
from pydantic import BaseModel
from pydantic.alias_generators import to_camel, to_snake
if not isinstance(obj, BaseModel):
return key
fields = type(obj).model_fields
if key in fields:
return key
normalized = to_snake(key.replace("-", "_"))
if normalized in fields:
return normalized
for name, field in fields.items():
aliases = {
to_camel(name),
str(field.alias) if field.alias else "",
str(field.serialization_alias) if field.serialization_alias else "",
}
if key in aliases:
return name
raise AttributeError(f"Unknown config path segment {key!r}")
def _set_config_cli_value(config: Any, path: str, value: Any) -> None:
parts = [part for part in path.split(".") if part]
if not parts:
raise ValueError("Config path cannot be empty.")
current = config
for raw_part in parts[:-1]:
if isinstance(current, dict):
current = current.setdefault(raw_part, {})
continue
part = _resolve_config_field(current, raw_part)
current = getattr(current, part)
leaf = parts[-1]
if isinstance(current, dict):
current[leaf] = value
return
leaf = _resolve_config_field(current, leaf)
setattr(current, leaf, value)
# ============================================================================
# OAuth Login
# ============================================================================
@@ -1541,6 +1643,7 @@ _LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {}
_PROVIDER_DISPLAY: dict[str, str] = {
"openai_codex": "OpenAI Codex",
"github_copilot": "GitHub Copilot",
"xai_oauth": "xAI Grok OAuth",
}
@@ -1576,7 +1679,9 @@ def _resolve_oauth_provider(provider: str):
@provider_app.command("login")
def provider_login(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot', 'xai-oauth')"),
no_browser: bool = typer.Option(False, "--no-browser", help="Print the auth URL instead of opening a browser when supported."),
manual_paste: bool = typer.Option(False, "--manual-paste", help="Prompt for a callback URL or fallback code when supported."),
):
"""Authenticate with an OAuth provider."""
spec = _resolve_oauth_provider(provider)
@@ -1587,12 +1692,18 @@ def provider_login(
raise typer.Exit(1)
console.print(f"{__logo__} OAuth Login - {spec.label}\n")
handler()
params = signature(handler).parameters
kwargs: dict[str, bool] = {}
if "no_browser" in params:
kwargs["no_browser"] = no_browser
if "manual_paste" in params:
kwargs["manual_paste"] = manual_paste
handler(**kwargs)
@provider_app.command("logout")
def provider_logout(
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot')"),
provider: str = typer.Argument(..., help="OAuth provider (e.g. 'openai-codex', 'github-copilot', 'xai-oauth')"),
):
"""Log out from an OAuth provider."""
spec = _resolve_oauth_provider(provider)
@@ -1656,6 +1767,24 @@ def _logout_github_copilot() -> None:
_delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"])
@_register_logout("xai_oauth")
def _logout_xai_oauth() -> None:
"""Clear local OAuth credentials for xAI Grok OAuth."""
try:
from nanobot.providers.xai_oauth_provider import delete_xai_oauth_credentials
except ImportError:
console.print("[red]xAI Grok OAuth provider unavailable.[/red]")
raise typer.Exit(1)
removed_paths = delete_xai_oauth_credentials()
if not removed_paths:
console.print(f"[yellow]! No local OAuth credentials found for {_PROVIDER_DISPLAY['xai_oauth']}[/yellow]")
return
console.print(f"[green]✓ Logged out from {_PROVIDER_DISPLAY['xai_oauth']}[/green]")
for path in removed_paths:
console.print(f"[dim]Removed: {path}[/dim]")
def _delete_oauth_files(token_path: Path, provider_label: str) -> None:
"""Delete OAuth token and lock files, reporting the result."""
removed_paths: list[Path] = []
@@ -1699,5 +1828,36 @@ def _login_github_copilot() -> None:
raise typer.Exit(1)
@_register_login("xai_oauth")
def _login_xai_oauth(
*,
no_browser: bool = False,
manual_paste: bool = False,
) -> None:
try:
from nanobot.providers.xai_oauth_provider import login_xai_oauth_interactive
from nanobot.providers.xai_oauth_provider import DEFAULT_XAI_MODEL
console.print("[cyan]Starting xAI Grok OAuth login...[/cyan]\n")
credential = login_xai_oauth_interactive(
print_fn=lambda s: console.print(s),
prompt_fn=lambda s: typer.prompt(s),
open_browser=not no_browser,
manual_paste=manual_paste,
)
account = credential.account_id or "xAI"
storage = "OS keychain" if credential.storage == "keyring" else "private file"
console.print(f"[green]✓ Authenticated with xAI Grok OAuth[/green] [dim]{account} · {storage}[/dim]")
console.print("[dim]To use it for chat:[/dim]")
console.print("[dim] nanobot config set agents.defaults.model_preset null[/dim]")
console.print("[dim] nanobot config set agents.defaults.provider xai-oauth[/dim]")
console.print(f"[dim] nanobot config set agents.defaults.model {DEFAULT_XAI_MODEL}[/dim]")
console.print("[dim]Hosted X Search is enabled by default for xAI OAuth.[/dim]")
console.print("[dim]To disable it: nanobot config set providers.xai_oauth.x_search.enable false[/dim]")
except Exception as e:
console.print(f"[red]Authentication error: {e}[/red]")
raise typer.Exit(1)
if __name__ == "__main__":
app()
-13
View File
@@ -1,13 +0,0 @@
"""CLI Apps integration helpers."""
from nanobot.cli_apps.service import (
CliAppError,
CliAppManager,
CliAppsRuntimeConfig,
)
__all__ = [
"CliAppError",
"CliAppManager",
"CliAppsRuntimeConfig",
]
-955
View File
@@ -1,955 +0,0 @@
"""CLI-Anything catalog, install state, and safe CLI execution."""
from __future__ import annotations
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import httpx
from nanobot.config.paths import get_runtime_subdir
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"
CLI_ANYTHING_RAW_SKILLS_BASE = f"{CLI_ANYTHING_RAW_BASE}/skills/"
_MAX_TOOL_OUTPUT_CHARS = 12_000
_MAX_ARTIFACT_SCAN_PATHS = 4_000
_MAX_ARTIFACT_REPORT = 12
_SAFE_NAME_RE = re.compile(r"[^a-z0-9_-]+")
_MENTION_RE = re.compile(r"(^|[\s([{])@([a-z0-9_-]+)\b", re.IGNORECASE)
_SHELL_META_CHARS = ("|", "&&", "||", ";", "$(", "`", ">", "<")
_ARTIFACT_EXTENSIONS = frozenset({
".csv",
".drawio",
".gif",
".html",
".jpeg",
".jpg",
".json",
".md",
".pdf",
".png",
".svg",
".txt",
".vsdx",
".webp",
".xml",
})
_INLINE_ARTIFACT_EXTENSIONS = frozenset({".gif", ".jpeg", ".jpg", ".png", ".webp"})
_ARTIFACT_IGNORE_DIRS = frozenset({
".git",
".hg",
".mypy_cache",
".nanobot",
".pytest_cache",
".ruff_cache",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"venv",
})
class CliAppError(ValueError):
"""User-facing CLI Apps failure."""
def __init__(self, message: str, *, status: int = 400) -> None:
super().__init__(message)
self.message = message
self.status = status
@dataclass(slots=True)
class CliAppsRuntimeConfig:
"""Runtime knobs for CLI Apps."""
install_timeout: int = 300
run_timeout: int = 60
catalog_ttl_seconds: int = 3600
_BRANDS: dict[str, tuple[str, str]] = {
"1password-cli": ("1password", "#3B66BC"),
"audacity": ("audacity", "#0000CC"),
"blender": ("blender", "#E87D0D"),
"browser": ("googlechrome", "#4285F4"),
"calibre": ("calibre", "#45B29D"),
"chromadb": ("chroma", "#FFDE2D"),
"comfyui": ("comfyui", "#111827"),
"contentful": ("contentful", "#2478CC"),
"dify": ("dify", "#155EEF"),
"drawio": ("diagramsdotnet", "#F08705"),
"elevenlabs": ("elevenlabs", "#000000"),
"eth2-quickstart": ("ethereum", "#627EEA"),
"firefly-iii": ("fireflyiii", "#CD5029"),
"freecad": ("freecad", "#418FDE"),
"generate-veo-video": ("googlegemini", "#8E75B2"),
"gimp": ("gimp", "#5C5543"),
"godot": ("godotengine", "#478CBF"),
"hacker-feeds-cli": ("rss", "#FFA500"),
"inkscape": ("inkscape", "#000000"),
"intelwatch": ("intel", "#0071C5"),
"iterm2": ("iterm2", "#000000"),
"jimeng": ("bytedance", "#3C8CFF"),
"kdenlive": ("kdenlive", "#527EB2"),
"krita": ("krita", "#3BABFF"),
"libreoffice": ("libreoffice", "#18A303"),
"mailchimp": ("mailchimp", "#FFE01B"),
"mermaid": ("mermaid", "#FF3670"),
"minimax": ("minimax", "#111827"),
"musescore": ("musescore", "#1A70B8"),
"n8n": ("n8n", "#EA4B71"),
"notebooklm": ("googlenotebooklm", "#4285F4"),
"obs-studio": ("obsstudio", "#302E31"),
"obsidian": ("obsidian", "#7C3AED"),
"ollama": ("ollama", "#000000"),
"pm2": ("pm2", "#2B037A"),
"qgis": ("qgis", "#589632"),
"safari": ("safari", "#006CFF"),
"sanity": ("sanity", "#F03E2F"),
"sentry": ("sentry", "#362D59"),
"sketch": ("sketch", "#F7B500"),
"shopify": ("shopify", "#7AB55C"),
"nsight-graphics": ("nvidia", "#76B900"),
"unrealinsights": ("unrealengine", "#0E1128"),
"ueatelier": ("unrealengine", "#0E1128"),
"ve-twini": ("x", "#000000"),
"wecom": ("wechat", "#07C160"),
"suno": ("suno", "#000000"),
"lldb": ("llvm", "#262D3A"),
"android-cli": ("android", "#3DDC84"),
"adguardhome": ("adguard", "#68BC71"),
"zotero": ("zotero", "#CC2936"),
"zoom": ("zoom", "#0B5CFF"),
}
_BRAND_DOMAINS: dict[str, tuple[str, str]] = {
"3mf": ("3mf.io", "#00A1DE"),
"anygen": ("anygen.com", "#111827"),
"clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"),
"cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"),
"cloudcompare": ("cloudcompare.org", "#4D83C3"),
"deployhq": ("deployhq.com", "#00A2D9"),
"exa": ("exa.ai", "#111827"),
"feishu": ("larksuite.com", "#00A5FF"),
"inkstitch": ("inkstitch.org", "#222222"),
"macrocli": ("github.com/HKUDS/CLI-Anything/tree/main/macrocli", "#24292F"),
"mubu": ("mubu.com", "#16A085"),
"nslogger": ("github.com/fpillet/NSLogger", "#24292F"),
"novita": ("novita.ai", "#7C3AED"),
"openscreen": ("openscreen.com", "#2563EB"),
"py4csr": ("github.com/yanmingyu92/py4csr", "#24292F"),
"quietshrink": ("github.com/achiya-automation/quietshrink", "#111827"),
"renderdoc": ("renderdoc.org", "#2C7DB8"),
"rms": ("rms.teltonika-networks.com", "#0054A6"),
"sbox": ("sbox.game", "#F59E0B"),
"seaclip": ("github.com/SeaClip-Lite/SeaClip", "#0284C7"),
"shotcut": ("shotcut.org", "#3B82F6"),
"slay-the-spire-ii": ("megacrit.com", "#B91C1C"),
"stata": ("stata.com", "#1F4E79"),
"unimol-tools": ("github.com/deepmodeling/Uni-Mol", "#4F46E5"),
"videocaptioner": ("github.com/WEIFENG2333/VideoCaptioner", "#2563EB"),
"wiremock": ("wiremock.org", "#FF6A00"),
}
_BRAND_ALIASES: dict[str, str] = {
"1password": "1password-cli",
"dify-workflow": "dify",
"feishu-lark": "feishu",
"lark-cli": "feishu",
"minimax-cli": "minimax",
"obsidian-cli": "obsidian",
"slay-the-spire-2": "slay-the-spire-ii",
"slay-the-spire-ii": "slay-the-spire-ii",
"unimol-tools": "unimol-tools",
"unimol": "unimol-tools",
"veo": "generate-veo-video",
}
_BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools")
def _now() -> float:
return time.time()
def _safe_skill_name(name: str) -> str:
clean = _SAFE_NAME_RE.sub("-", name.lower()).strip("-")
return f"cli-app-{clean or 'app'}"
def _has_shell_meta(command: str) -> bool:
return any(char in command for char in _SHELL_META_CHARS)
def _command_exists(command: str) -> bool:
try:
parts = shlex.split(command)
except ValueError:
return False
if not parts:
return False
return shutil.which(parts[0]) is not None
def _is_pip_install_command(command: str) -> bool:
try:
tokens = shlex.split(command)
except ValueError:
return False
return (
len(tokens) >= 3
and tokens[:2] == ["pip", "install"]
) or (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "install"]
and tokens[0] in {"python", "python3", sys.executable}
)
def _pip_uninstall_args_from_command(command: str) -> list[str] | None:
if not command or _has_shell_meta(command):
return None
try:
tokens = shlex.split(command)
except ValueError:
return None
if tokens[:2] == ["pip", "uninstall"]:
args = tokens[2:]
elif (
len(tokens) >= 5
and tokens[1:4] == ["-m", "pip", "uninstall"]
and tokens[0] in {"python", "python3", sys.executable}
):
args = tokens[4:]
else:
return None
packages = [arg for arg in args if arg not in {"-y", "--yes"}]
if not packages or any(arg.startswith("-") for arg in packages):
return None
return packages
def _brand_key(value: str) -> str:
return _SAFE_NAME_RE.sub("-", value.lower()).replace("_", "-").strip("-")
def _brand_candidates(app: dict[str, Any]) -> list[str]:
values = [
str(app.get("name") or ""),
str(app.get("display_name") or ""),
str(app.get("entry_point") or "").removeprefix("cli-anything-"),
]
seen: set[str] = set()
candidates: list[str] = []
for value in values:
key = _brand_key(value)
while key and key not in seen:
seen.add(key)
candidates.append(key)
parts = key.split("-")
if len(parts) <= 1 or parts[-1] not in _BRAND_TRAILING_WORDS:
break
key = "-".join(parts[:-1])
return candidates
def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]:
brand = None
domain_brand = None
for candidate in _brand_candidates(app):
key = _BRAND_ALIASES.get(candidate, candidate)
brand = _BRANDS.get(key)
if brand:
break
domain_brand = _BRAND_DOMAINS.get(key)
if domain_brand:
break
if not brand:
if not domain_brand:
return None, None
domain, color = domain_brand
return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color
slug, color = brand
return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color
def _read_json(path: Path) -> dict[str, Any] | None:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False)
tmp_path = path.with_name(f".{path.name}.{os.getpid()}.{int(_now() * 1_000_000)}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
finally:
if tmp_path.exists():
tmp_path.unlink()
def _safe_skill_path(value: str) -> str | None:
if not value.startswith("skills/"):
return None
parts = value.split("/")
if any(part in {"", ".", ".."} for part in parts):
return None
return value if parts[-1] == "SKILL.md" else None
def _skill_content_url(skill_md: str) -> str | None:
safe_path = _safe_skill_path(skill_md)
if safe_path:
return f"{CLI_ANYTHING_RAW_BASE}/{safe_path}"
parsed = urlparse(skill_md)
if parsed.scheme != "https" or parsed.netloc != "raw.githubusercontent.com":
return None
if not skill_md.startswith(CLI_ANYTHING_RAW_SKILLS_BASE):
return None
suffix = skill_md.removeprefix(f"{CLI_ANYTHING_RAW_BASE}/")
return skill_md if _safe_skill_path(suffix) else None
def _truncate(text: str, limit: int = _MAX_TOOL_OUTPUT_CHARS) -> str:
if len(text) <= limit:
return text
omitted = len(text) - limit
return text[:limit] + f"\n\n... truncated {omitted} characters ..."
class CliAppManager:
"""Manage CLI-Anything registry entries and local install state."""
def __init__(
self,
*,
workspace: Path,
data_dir: Path | None = None,
runtime: CliAppsRuntimeConfig | None = None,
) -> None:
self.workspace = Path(workspace).expanduser()
self.data_dir = Path(data_dir) if data_dir is not None else get_runtime_subdir("cli-apps")
self.runtime = runtime or CliAppsRuntimeConfig()
@property
def installed_path(self) -> Path:
return self.data_dir / "installed.json"
def _cache_path(self, source: str) -> Path:
return self.data_dir / f"{source}_registry_cache.json"
def _load_installed(self) -> dict[str, Any]:
data = _read_json(self.installed_path) or {}
apps = data.get("apps") if isinstance(data.get("apps"), dict) else data
return apps if isinstance(apps, dict) else {}
def _save_installed(self, installed: dict[str, Any]) -> None:
_write_json(self.installed_path, {"schema_version": 1, "apps": installed})
def installed_names(self) -> list[str]:
"""Return registry names explicitly installed through CLI Apps."""
return sorted(str(name) for name in self._load_installed())
def _fetch_registry(
self,
url: str,
cache_path: Path,
*,
force_refresh: bool = False,
) -> dict[str, Any]:
cached = _read_json(cache_path)
if (
not force_refresh
and cached
and _now() - float(cached.get("_cached_at", 0)) < self.runtime.catalog_ttl_seconds
):
data = cached.get("data")
if isinstance(data, dict):
return data
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
data = response.json()
if not isinstance(data, dict):
raise ValueError("registry response must be an object")
except Exception:
if cached and isinstance(cached.get("data"), dict):
return cached["data"]
raise
_write_json(cache_path, {"_cached_at": _now(), "data": data})
return data
def catalog(self, *, force_refresh: bool = False) -> tuple[list[dict[str, Any]], str | None]:
registries = [
(
"harness",
self._fetch_registry(
CLI_ANYTHING_REGISTRY_URL,
self._cache_path("harness"),
force_refresh=force_refresh,
),
),
(
"public",
self._fetch_registry(
CLI_ANYTHING_PUBLIC_REGISTRY_URL,
self._cache_path("public"),
force_refresh=force_refresh,
),
),
]
apps_by_name: dict[str, dict[str, Any]] = {}
updated_values: list[str] = []
for source, registry in registries:
meta = registry.get("meta")
if isinstance(meta, dict) and isinstance(meta.get("updated"), str):
updated_values.append(meta["updated"])
for row in registry.get("clis", []):
if not isinstance(row, dict) or not row.get("name"):
continue
entry = dict(row)
entry["_source"] = source
key = str(entry["name"]).lower()
previous = apps_by_name.get(key)
if previous:
previous_source = str(previous.get("_source") or source)
merged_source = (
previous_source if previous_source == source else f"{previous_source}+{source}"
)
apps_by_name[key] = {**previous, **entry, "_source": merged_source}
else:
apps_by_name[key] = entry
return list(apps_by_name.values()), max(updated_values) if updated_values else None
def get_app(self, name: str, *, force_refresh: bool = False) -> dict[str, Any]:
wanted = name.lower()
for app in self.catalog(force_refresh=force_refresh)[0]:
if str(app.get("name", "")).lower() == wanted:
return app
raise CliAppError(f"CLI app '{name}' not found", status=404)
def mentioned_installed_apps(self, text: str) -> list[dict[str, str]]:
"""Return installed CLI Apps referenced as ``@name`` in user text."""
if "@" not in text:
return []
installed = self._load_installed()
if not installed:
return []
installed_by_name = {
str(name).lower(): (str(name), data if isinstance(data, dict) else {})
for name, data in installed.items()
}
seen: set[str] = set()
mentions: list[dict[str, str]] = []
for match in _MENTION_RE.finditer(text):
wanted = str(match.group(2)).lower()
if wanted in seen or wanted not in installed_by_name:
continue
installed_name, data = installed_by_name[wanted]
seen.add(wanted)
entry_point = str(data.get("entry_point") or "")
mentions.append(
{
"name": installed_name,
"entry_point": entry_point,
"source": str(data.get("source") or ""),
"skill": f"skills/{_safe_skill_name(installed_name)}/SKILL.md",
"tool": "run_cli_app",
}
)
return mentions
def _strategy(self, app: dict[str, Any]) -> str:
package_manager = str(app.get("package_manager") or "").lower()
install_strategy = str(app.get("install_strategy") or "").lower()
if package_manager == "bundled" or install_strategy == "bundled":
return "bundled"
if package_manager in {"npm", "brew", "uv", "pip"}:
return package_manager
if app.get("npm_package"):
return "npm"
install_cmd = str(app.get("install_cmd") or "")
if _is_pip_install_command(install_cmd):
return "pip"
return "unsupported"
def _install_supported(self, app: dict[str, Any]) -> bool:
if self._strategy(app) == "unsupported":
return False
install_cmd = str(app.get("install_cmd") or "")
return not _has_shell_meta(install_cmd)
def _skill_path(self, name: str) -> Path:
return self.workspace / "skills" / _safe_skill_name(name) / "SKILL.md"
def _app_payload(
self,
app: dict[str, Any],
installed: dict[str, Any],
) -> dict[str, Any]:
name = str(app["name"])
entry_point = str(app.get("entry_point") or "")
install_supported = self._install_supported(app)
is_installed = name in installed
available = bool(entry_point and shutil.which(entry_point))
if is_installed and available:
status = "installed"
elif is_installed:
status = "missing"
elif not install_supported:
status = "unsupported"
elif available:
status = "available"
else:
status = "not_installed"
logo_url, brand_color = _brand_payload(app)
return {
"name": name,
"display_name": app.get("display_name") or name,
"category": app.get("category") or "uncategorized",
"description": app.get("description") or "",
"requires": app.get("requires") or "",
"source": app.get("_source") or "harness",
"entry_point": entry_point,
"install_supported": install_supported,
"installed": is_installed,
"available": available,
"status": status,
"logo_url": logo_url,
"brand_color": brand_color,
"skill_installed": self._skill_path(name).is_file(),
}
def payload(self, *, force_refresh: bool = False) -> dict[str, Any]:
apps, updated = self.catalog(force_refresh=force_refresh)
installed = self._load_installed()
rows = [self._app_payload(app, installed) for app in apps]
rows.sort(key=lambda item: (str(item["category"]), str(item["display_name"]).lower()))
return {
"apps": rows,
"installed_count": sum(1 for item in rows if item["installed"]),
"catalog_updated_at": updated,
}
def _pip_package_from_install(self, app: dict[str, Any]) -> str | None:
install_cmd = str(app.get("install_cmd") or "")
try:
tokens = shlex.split(install_cmd)
except ValueError:
return None
if tokens[:2] == ["pip", "install"]:
args = tokens[2:]
elif len(tokens) >= 5 and tokens[1:4] == ["-m", "pip", "install"]:
args = tokens[4:]
else:
return None
args = [arg for arg in args if not arg.startswith("-")]
if len(args) != 1 or args[0].startswith("git+"):
return None
return args[0]
def _pip_install_argv(self, app: dict[str, Any], *, update: bool = False) -> list[str]:
install_cmd = str(app.get("install_cmd") or "")
if not _is_pip_install_command(install_cmd) or _has_shell_meta(install_cmd):
raise CliAppError("unsupported pip install command")
tokens = shlex.split(install_cmd)
args = tokens[2:] if tokens[:2] == ["pip", "install"] else tokens[4:]
prefix = [sys.executable, "-m", "pip", "install"]
if update:
prefix.extend(["--upgrade", "--force-reinstall"])
return prefix + args
def _pip_uninstall_argv(self, app: dict[str, Any]) -> list[str]:
uninstall_cmd = str(app.get("uninstall_cmd") or "")
packages = _pip_uninstall_args_from_command(uninstall_cmd)
if packages:
return [sys.executable, "-m", "pip", "uninstall", "-y", *packages]
package = str(app.get("pip_package") or "").strip() or self._pip_package_from_install(app)
if not package:
entry_point = str(app.get("entry_point") or "").strip()
package = entry_point if entry_point.startswith("cli-anything-") else f"cli-anything-{_brand_key(str(app['name']))}"
return [sys.executable, "-m", "pip", "uninstall", "-y", package]
def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]:
npm = shutil.which("npm")
if not npm:
raise CliAppError("npm is not installed")
package = str(app.get("npm_package") or "")
if not package:
raise CliAppError("registry entry has no npm_package")
if action == "install":
return [npm, "install", "-g", package]
if action == "update":
return [npm, "install", "-g", package + "@latest"]
return [npm, "uninstall", "-g", package]
def _split_safe_command(self, app: dict[str, Any], key: str, expected: str) -> list[str]:
command = str(app.get(key) or "")
if not command:
raise CliAppError(f"no {key} is defined for {app['name']}")
if _has_shell_meta(command):
raise CliAppError("script-style install commands are disabled in this MVP")
try:
argv = shlex.split(command)
except ValueError as exc:
raise CliAppError(f"invalid command: {exc}") from exc
if not argv or argv[0] != expected:
raise CliAppError(f"unsupported {expected} command")
return argv
def _argv_for_action(self, app: dict[str, Any], action: str) -> list[str] | None:
strategy = self._strategy(app)
if strategy == "pip":
if action == "install":
return self._pip_install_argv(app)
if action == "update":
return self._pip_install_argv(app, update=True)
return self._pip_uninstall_argv(app)
if strategy == "npm":
return self._npm_argv(app, action)
if strategy == "brew":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "brew")
if strategy == "uv":
key = {"install": "install_cmd", "update": "update_cmd", "uninstall": "uninstall_cmd"}[action]
return self._split_safe_command(app, key, "uv")
if strategy == "bundled":
return None
raise CliAppError("this CLI app uses an unsupported install strategy")
def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
return subprocess.run(
argv,
capture_output=True,
text=True,
timeout=timeout,
)
def _installed_entry(self, app: dict[str, Any]) -> dict[str, Any]:
return {
"version": app.get("version") or "unknown",
"entry_point": app.get("entry_point") or "",
"source": app.get("_source") or "harness",
"strategy": self._strategy(app),
"installed_at": int(_now()),
}
def _fetch_skill_content(self, app: dict[str, Any]) -> str | None:
skill_md = str(app.get("skill_md") or "").strip()
if not skill_md:
return None
url = _skill_content_url(skill_md)
if not url:
return None
try:
response = httpx.get(url, timeout=15.0, follow_redirects=True)
response.raise_for_status()
text = response.text
except Exception:
return None
if "SKILL.md" not in url and not text.lstrip().startswith("---"):
return None
return text if len(text) < 250_000 else None
def _fallback_skill(self, app: dict[str, Any]) -> str:
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 = str(app.get("description") or f"Use {display} from nanobot.")
return f"""---
name: {_safe_skill_name(name)}
description: >-
{description}
---
# {display}
Use this skill when the user asks nanobot to operate {display} through its installed CLI app.
If the user attached `@{name}` in chat, treat that as the selected app for the current turn.
## Commands
```bash
{entry} --help
{entry} --json --help
```
Prefer machine-readable output when the CLI supports `--json`.
"""
def _with_nanobot_skill_note(self, content: str, app: dict[str, Any]) -> str:
marker = "<!-- nanobot-cli-app-note -->"
if marker in content:
return content
name = str(app.get("name") or "unknown")
note = f"""{marker}
## Nanobot execution
Use the `run_cli_app` tool with `name="{name}"` for command execution. Do not invoke this CLI through shell unless the user explicitly asks. Prefer this skill when Runtime Context mentions `@{name}` as a CLI App Attachment.
"""
lines = content.splitlines(keepends=True)
if lines and lines[0].strip() == "---":
for index, line in enumerate(lines[1:], start=1):
if line.strip() == "---":
return "".join(lines[: index + 1]) + "\n" + note + "\n" + "".join(lines[index + 1 :])
return note + "\n" + content
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._with_nanobot_skill_note(content, app)
path.write_text(content, encoding="utf-8")
return path
def remove_skill(self, name: str) -> None:
skill_dir = self._skill_path(name).parent
if skill_dir.is_dir():
shutil.rmtree(skill_dir)
def _record_installed(self, app: dict[str, Any]) -> None:
installed = self._load_installed()
installed[str(app["name"])] = self._installed_entry(app)
self._save_installed(installed)
self.install_skill(app)
def install(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
if not self._install_supported(app):
raise CliAppError("this CLI app uses an unsupported install strategy")
strategy = self._strategy(app)
if strategy == "bundled":
detect_cmd = str(app.get("detect_cmd") or app.get("entry_point") or "")
if detect_cmd and _command_exists(detect_cmd):
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"CLI for {app['display_name']} is available."}}
note = app.get("install_notes") or f"{app['display_name']} is bundled with its parent app."
raise CliAppError(str(note))
argv = self._argv_for_action(app, "install")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "install failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Installed CLI for {app['display_name']}."}}
def update(self, name: str) -> dict[str, Any]:
app = self.get_app(name, force_refresh=True)
if str(app["name"]) not in self._load_installed():
raise CliAppError("CLI app is not installed")
if self._strategy(app) == "bundled":
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Checked {app['display_name']}."}}
argv = self._argv_for_action(app, "update")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "update failed"), status=500)
self._record_installed(app)
return self.payload() | {"last_action": {"ok": True, "message": f"Updated CLI for {app['display_name']}."}}
def uninstall(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError("CLI app is not installed")
if self._strategy(app) != "bundled":
argv = self._argv_for_action(app, "uninstall")
assert argv is not None
result = self._run_argv(argv, timeout=self.runtime.install_timeout)
if result.returncode != 0:
raise CliAppError(_truncate(result.stderr or result.stdout or "uninstall failed"), status=500)
installed.pop(str(app["name"]), None)
self._save_installed(installed)
self.remove_skill(str(app["name"]))
return self.payload() | {"last_action": {"ok": True, "message": f"Uninstalled CLI for {app['display_name']}."}}
def test(self, name: str) -> dict[str, Any]:
app = self.get_app(name)
entry = str(app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
result = self._run_argv([resolved, "--help"], timeout=min(self.runtime.run_timeout, 30))
ok = result.returncode == 0
output = _truncate((result.stdout or result.stderr or "").strip(), 3000)
return self.payload() | {
"last_action": {
"ok": ok,
"message": f"{entry} --help exited {result.returncode}",
"output": output,
}
}
def _resolve_cwd(
self,
working_dir: str | None,
*,
restrict_to_workspace: bool,
) -> Path:
cwd = Path(working_dir).expanduser() if working_dir else self.workspace
cwd = cwd.resolve(strict=False)
workspace = self.workspace.resolve(strict=False)
if restrict_to_workspace and cwd != workspace and not cwd.is_relative_to(workspace):
raise CliAppError("working_dir is outside the configured workspace")
return cwd
def _iter_artifact_candidates(self, cwd: Path) -> list[Path]:
if not cwd.is_dir():
return []
out: list[Path] = []
stack = [cwd]
scanned = 0
while stack and scanned < _MAX_ARTIFACT_SCAN_PATHS:
directory = stack.pop()
try:
entries = sorted(directory.iterdir(), key=lambda path: path.name.lower())
except OSError:
continue
for path in entries:
if scanned >= _MAX_ARTIFACT_SCAN_PATHS:
break
scanned += 1
try:
if path.is_dir() and not path.is_symlink():
if path.name not in _ARTIFACT_IGNORE_DIRS:
stack.append(path)
continue
if path.is_file() and path.suffix.lower() in _ARTIFACT_EXTENSIONS:
out.append(path.resolve(strict=False))
except OSError:
continue
return out
def _artifact_snapshot(self, cwd: Path) -> dict[Path, tuple[int, int]]:
snapshot: dict[Path, tuple[int, int]] = {}
for path in self._iter_artifact_candidates(cwd):
try:
stat = path.stat()
except OSError:
continue
snapshot[path] = (stat.st_mtime_ns, stat.st_size)
return snapshot
def _changed_artifacts(
self,
cwd: Path,
before: dict[Path, tuple[int, int]],
) -> list[Path]:
changed: list[tuple[int, Path]] = []
for path, stamp in self._artifact_snapshot(cwd).items():
if before.get(path) == stamp:
continue
changed.append((stamp[0], path))
changed.sort(key=lambda item: (item[0], item[1].name.lower()))
return [path for _, path in changed[-_MAX_ARTIFACT_REPORT:]]
def _format_artifact_path(self, cwd: Path, path: Path) -> str:
try:
return path.relative_to(cwd).as_posix()
except ValueError:
return path.name
@staticmethod
def _format_artifact_size(path: Path) -> str:
try:
size = path.stat().st_size
except OSError:
return "unknown size"
if size < 1024:
return f"{size} B"
if size < 1024 * 1024:
return f"{size / 1024:.1f} KB"
return f"{size / (1024 * 1024):.1f} MB"
def _format_artifact_lines(self, cwd: Path, paths: list[Path]) -> list[str]:
lines: list[str] = []
for path in paths:
rel = self._format_artifact_path(cwd, path)
ext = path.suffix.lower()
kind = (
"previewable image"
if ext in _INLINE_ARTIFACT_EXTENSIONS
else ext.lstrip(".") or "file"
)
lines.append(f"- {rel} ({kind}, {self._format_artifact_size(path)})")
return lines
def run(
self,
name: str,
args: list[str] | None = None,
*,
json_output: bool = False,
working_dir: str | None = None,
timeout: int | None = None,
restrict_to_workspace: bool = False,
) -> str:
app = self.get_app(name)
installed = self._load_installed()
if str(app["name"]) not in installed:
raise CliAppError(f"CLI app '{name}' is not installed")
cwd = self._resolve_cwd(working_dir, restrict_to_workspace=restrict_to_workspace)
entry = str(installed[str(app["name"])].get("entry_point") or app.get("entry_point") or "")
resolved = shutil.which(entry)
if not entry or not resolved:
raise CliAppError(f"{entry or name} is not available on PATH")
clean_args = [str(arg) for arg in (args or [])]
if json_output and "--json" not in clean_args:
clean_args = ["--json", *clean_args]
effective_timeout = max(1, min(timeout or self.runtime.run_timeout, 600))
artifact_snapshot = self._artifact_snapshot(cwd)
try:
result = subprocess.run(
[resolved, *clean_args],
cwd=str(cwd),
capture_output=True,
text=True,
timeout=effective_timeout,
env=os.environ.copy(),
)
except subprocess.TimeoutExpired:
return f"CLI app '{name}' timed out after {effective_timeout}s"
output = [
f"CLI app '{name}' exited {result.returncode}.",
f"Command: {entry} {' '.join(shlex.quote(arg) for arg in clean_args)}".rstrip(),
]
if result.stdout:
output.append("\nSTDOUT:\n" + result.stdout.rstrip())
if result.stderr:
output.append("\nSTDERR:\n" + result.stderr.rstrip())
artifacts = self._changed_artifacts(cwd, artifact_snapshot)
if artifacts:
output.append(
"\nArtifacts created or updated:\n"
+ "\n".join(self._format_artifact_lines(cwd, artifacts))
)
if any(path.suffix.lower() in _INLINE_ARTIFACT_EXTENSIONS for path in artifacts):
output.append(
"\nTo show a preview in WebUI, reference a raster artifact with Markdown "
"using its workspace-relative path, for example `![diagram](diagram.png)`."
)
return _truncate("\n".join(output))
-62
View File
@@ -1,62 +0,0 @@
"""CLI Apps helpers shared by the agent loop and settings surfaces."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted session kwargs for CLI app attachments."""
cli_apps = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
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 _cli_app_runtime_lines(text, metadata, workspace)
def _cli_app_runtime_lines(
text: str,
metadata: Mapping[str, Any] | None,
workspace: Path,
) -> list[str]:
structured = metadata.get("cli_apps") if isinstance(metadata, Mapping) else None
if isinstance(structured, list):
mentions = [
item for item in structured
if isinstance(item, Mapping) and isinstance(item.get("name"), str)
]
if mentions:
return [
"CLI App Attachment: "
f"@{str(item['name']).strip().lower()} "
f"(installed; tool=run_cli_app; "
f"entry_point={str(item.get('entry_point') or 'unknown')}; "
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()
]
if "@" not in text:
return []
try:
from nanobot.cli_apps import CliAppManager
mentions = CliAppManager(workspace=workspace).mentioned_installed_apps(text)
except Exception:
return []
return [
"CLI App Mention: "
f"@{item['name']} "
f"(installed; tool={item['tool']}; "
f"entry_point={item['entry_point'] or 'unknown'}; "
f"skill={item['skill']}). "
"Read the skill when useful, then run this app with `run_cli_app`; do not bypass it with shell."
for item in mentions
]
+26 -5
View File
@@ -11,7 +11,6 @@ from pydantic_settings import BaseSettings
from nanobot.cron.types import CronSchedule
if TYPE_CHECKING:
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@@ -181,6 +180,28 @@ class BedrockProviderConfig(ProviderConfig):
profile: str | None = None # Optional AWS shared config profile
class XaiOAuthXSearchConfig(Base):
"""xAI hosted X Search configuration."""
enable: bool = True
allowed_x_handles: list[str] | None = None
excluded_x_handles: list[str] | None = None
from_date: str | None = None
to_date: str | None = None
enable_image_understanding: bool = False
enable_video_understanding: bool = False
class XaiOAuthProviderConfig(ProviderConfig):
"""xAI OAuth provider configuration."""
x_search: XaiOAuthXSearchConfig = Field(default_factory=XaiOAuthXSearchConfig)
def _is_default_xai_oauth_config(value: Any) -> bool:
return isinstance(value, XaiOAuthProviderConfig) and value == XaiOAuthProviderConfig()
class ProvidersConfig(Base):
"""Configuration for LLM providers."""
@@ -212,13 +233,16 @@ class ProvidersConfig(Base):
ant_ling: ProviderConfig = Field(default_factory=ProviderConfig) # Ant Ling
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动)
novita: ProviderConfig = Field(default_factory=ProviderConfig) # Novita AI
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎)
volcengine_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine Coding Plan
byteplus: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus (VolcEngine international)
byteplus_coding_plan: ProviderConfig = Field(default_factory=ProviderConfig) # BytePlus Coding Plan
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # OpenAI Codex (OAuth)
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig, exclude=True) # Github Copilot (OAuth)
xai_oauth: XaiOAuthProviderConfig = Field(
default_factory=XaiOAuthProviderConfig,
exclude_if=_is_default_xai_oauth_config,
) # xAI Grok OAuth
qianfan: ProviderConfig = Field(default_factory=ProviderConfig) # Qianfan (百度千帆)
nvidia: ProviderConfig = Field(default_factory=ProviderConfig) # NVIDIA NIM (nvapi- keys)
@@ -277,7 +301,6 @@ class ToolsConfig(Base):
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
image_generation: ImageGenerationToolConfig = Field(
default_factory=lambda: _lazy_default("nanobot.agent.tools.image_generation", "ImageGenerationToolConfig"),
@@ -464,7 +487,6 @@ def _resolve_tool_config_refs() -> None:
"""
import sys
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig
@@ -473,7 +495,6 @@ 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.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
mod.WebFetchConfig = WebFetchConfig # type: ignore[attr-defined]
+22 -2
View File
@@ -14,6 +14,7 @@ __all__ = [
"OpenAICompatProvider",
"OpenAICodexProvider",
"GitHubCopilotProvider",
"XaiOAuthProvider",
"AzureOpenAIProvider",
"BedrockProvider",
]
@@ -23,10 +24,23 @@ _LAZY_IMPORTS = {
"OpenAICompatProvider": ".openai_compat_provider",
"OpenAICodexProvider": ".openai_codex_provider",
"GitHubCopilotProvider": ".github_copilot_provider",
"XaiOAuthProvider": ".xai_oauth_provider",
"AzureOpenAIProvider": ".azure_openai_provider",
"BedrockProvider": ".bedrock_provider",
}
_LAZY_SUBMODULES = {
"anthropic_provider": ".anthropic_provider",
"openai_compat_provider": ".openai_compat_provider",
"openai_codex_provider": ".openai_codex_provider",
"github_copilot_provider": ".github_copilot_provider",
"xai_oauth_provider": ".xai_oauth_provider",
"azure_openai_provider": ".azure_openai_provider",
"bedrock_provider": ".bedrock_provider",
"factory": ".factory",
"registry": ".registry",
}
if TYPE_CHECKING:
from nanobot.providers.anthropic_provider import AnthropicProvider
from nanobot.providers.azure_openai_provider import AzureOpenAIProvider
@@ -34,12 +48,18 @@ if TYPE_CHECKING:
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.xai_oauth_provider import XaiOAuthProvider
def __getattr__(name: str):
"""Lazily expose provider implementations without importing all backends up front."""
module_name = _LAZY_IMPORTS.get(name)
if module_name is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
if module_name is not None:
module = import_module(module_name, __name__)
return getattr(module, name)
module_name = _LAZY_SUBMODULES.get(name)
if module_name is not None:
module = import_module(module_name, __name__)
globals()[name] = module
return module
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+4
View File
@@ -68,6 +68,10 @@ def _make_provider_core(
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
provider = GitHubCopilotProvider(default_model=model)
elif backend == "xai_oauth":
from nanobot.providers.xai_oauth_provider import XaiOAuthProvider
provider = XaiOAuthProvider(default_model=model, config=p)
elif backend == "anthropic":
from nanobot.providers.anthropic_provider import AnthropicProvider
+16 -585
View File
@@ -2,10 +2,8 @@
from __future__ import annotations
import asyncio
import base64
import binascii
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
@@ -33,14 +31,6 @@ _AIHUBMIX_ASPECT_RATIO_SIZES = {
}
_GEMINI_DEFAULT_TIMEOUT_S = 120.0
_GEMINI_IMAGEN_ASPECT_RATIOS = {"1:1", "9:16", "16:9", "3:4", "4:3"}
_OLLAMA_DEFAULT_SIDE = 1024
_OLLAMA_SIZE_PRESETS = {
"1K": 1024,
"2K": 2048,
"4K": 4096,
}
_OLLAMA_EXPLICIT_SIZE_RE = re.compile(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$")
_OLLAMA_ASPECT_RATIO_RE = re.compile(r"^\s*(\d+)\s*:\s*(\d+)\s*$")
class ImageGenerationError(RuntimeError):
@@ -139,11 +129,6 @@ _IMAGE_GEN_PROVIDERS: dict[str, type[ImageGenerationProvider]] = {}
def register_image_gen_provider(cls: type[ImageGenerationProvider]) -> None:
"""Register an image provider at import time only.
The registry is populated by module side effects so provider discovery
stays lazy and consistent across the process.
"""
name = cls.provider_name
if not name:
raise ValueError(f"{cls.__name__} must set provider_name")
@@ -234,10 +219,7 @@ class ImageGenerationProvider(ABC):
*,
headers: dict[str, str],
body: dict[str, Any],
client: httpx.AsyncClient | None = None,
) -> httpx.Response:
if client is not None:
return await client.post(url, headers=headers, json=body)
if self._client is not None:
return await self._client.post(url, headers=headers, json=body)
async with httpx.AsyncClient(timeout=self.timeout) as c:
@@ -408,11 +390,10 @@ class AIHubMixImageGenerationClient(ImageGenerationProvider):
model_path = _aihubmix_model_path(model)
url = f"{self.api_base}/models/{model_path}/predictions"
try:
response = await self._http_post(
response = await client.post(
url,
headers={**headers, "Content-Type": "application/json"},
body=body,
client=client,
json=body,
)
except httpx.TimeoutException as exc:
raise ImageGenerationError("AIHubMix image generation timed out") from exc
@@ -448,139 +429,6 @@ def _http_error_detail(response: httpx.Response) -> str:
return response.text[:500] or "<empty response body>"
def _round_to_multiple(value: float, multiple: int = 8) -> int:
rounded = int(round(value / multiple) * multiple)
return max(multiple, rounded)
def _ollama_dimensions(aspect_ratio: str | None, image_size: str | None) -> tuple[int, int]:
if image_size:
size = image_size.strip()
explicit = _OLLAMA_EXPLICIT_SIZE_RE.fullmatch(size)
if explicit:
return int(explicit.group(1)), int(explicit.group(2))
long_side = _OLLAMA_SIZE_PRESETS.get(size.upper(), _OLLAMA_DEFAULT_SIDE)
else:
long_side = _OLLAMA_DEFAULT_SIDE
if not aspect_ratio:
return long_side, long_side
ratio = _OLLAMA_ASPECT_RATIO_RE.fullmatch(aspect_ratio.strip())
if ratio is None:
return long_side, long_side
width_ratio = int(ratio.group(1))
height_ratio = int(ratio.group(2))
if width_ratio <= 0 or height_ratio <= 0:
return long_side, long_side
if width_ratio >= height_ratio:
width = long_side
height = _round_to_multiple(long_side * height_ratio / width_ratio)
else:
height = long_side
width = _round_to_multiple(long_side * width_ratio / height_ratio)
return max(8, width), max(8, height)
def _ollama_image_data_url(value: str) -> str:
if value.startswith("data:image/"):
return value
return _b64_image_data_url(value)
def _ollama_images_from_payload(payload: dict[str, Any]) -> list[str]:
images: list[str] = []
def collect(value: Any) -> None:
if isinstance(value, str) and value:
images.append(_ollama_image_data_url(value))
elif isinstance(value, list):
for item in value:
collect(item)
collect(payload.get("image"))
collect(payload.get("images"))
return images
class OllamaImageGenerationClient(ImageGenerationProvider):
"""Async client for Ollama native image generation models."""
provider_name = "ollama"
default_timeout = 300.0
def _default_base_url(self) -> str:
return "http://localhost:11434/api"
def _resolve_base_url(self, api_base: str | None) -> str:
if api_base:
base = api_base.rstrip("/")
if base.endswith("/v1"):
return f"{base[:-3]}/api"
return base
return self._default_base_url()
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if reference_images:
raise ImageGenerationError(
"Ollama image generation does not support reference images"
)
width, height = _ollama_dimensions(aspect_ratio, image_size)
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"width": width,
"height": height,
"steps": 0,
}
body.update(self.extra_body)
body["stream"] = False
headers = {
"Content-Type": "application/json",
**self.extra_headers,
}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
url = f"{self.api_base}/generate"
response = await self._http_post(url, headers=headers, body=body)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = _http_error_detail(response)
logger.error(
"Ollama image generation failed (HTTP {}): {}",
response.status_code,
detail,
)
raise ImageGenerationError(
f"Ollama image generation failed (HTTP {response.status_code}): {detail}"
) from exc
data = response.json()
images = _ollama_images_from_payload(data)
self._require_images(images, data)
response_text = data.get("response")
content = response_text if isinstance(response_text, str) else ""
return GeneratedImageResponse(images=images, content=content, raw=data)
class GeminiImageGenerationClient(ImageGenerationProvider):
"""Async client for Gemini/Imagen image generation via the Generative Language API."""
@@ -594,9 +442,9 @@ class GeminiImageGenerationClient(ImageGenerationProvider):
return "https://generativelanguage.googleapis.com/v1beta"
def _resolve_base_url(self, api_base: str | None) -> str:
# Gemini chat completions use the registry's OpenAI-compatible shim.
# Image generation must hit the native Generative Language API, so we
# intentionally bypass the shared registry lookup here.
# The Gemini provider's registry default_api_base is the OpenAI-compat
# shim (.../v1beta/openai/), which has no image endpoints.
# Skip the registry lookup and use the native API base directly.
if api_base:
return api_base.rstrip("/")
return self._default_base_url()
@@ -858,16 +706,22 @@ class MiniMaxImageGenerationClient(ImageGenerationProvider):
body.update(self.extra_body)
return await self._generate_with_client(body, headers)
client = self._client or httpx.AsyncClient(timeout=self.timeout)
try:
return await self._generate_with_client(client, body, headers)
finally:
if self._client is None:
await client.aclose()
async def _generate_with_client(
self,
client: httpx.AsyncClient,
body: dict[str, Any],
headers: dict[str, str],
) -> GeneratedImageResponse:
url = f"{self.api_base}/image_generation"
try:
response = await self._http_post(url, headers=headers, body=body)
response = await client.post(url, headers=headers, json=body)
except httpx.TimeoutException as exc:
raise ImageGenerationError("MiniMax image generation timed out") from exc
except httpx.RequestError as exc:
@@ -902,426 +756,6 @@ def _minimax_images_from_payload(payload: dict[str, Any]) -> list[str]:
return images
# ---------------------------------------------------------------------------
# OpenAI image generation
# ---------------------------------------------------------------------------
_OPENAI_DALLE2_SUPPORTED_SIZES = {"256x256", "512x512", "1024x1024"}
_OPENAI_DALLE3_SUPPORTED_SIZES = {"1024x1024", "1792x1024", "1024x1792"}
_OPENAI_GPT_IMAGE_SUPPORTED_SIZES = {
"1024x1024",
"1536x1024",
"1024x1536",
"auto",
}
_OPENAI_DALLE2_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1024x1024",
"9:16": "1024x1024",
"3:4": "1024x1024",
"4:3": "1024x1024",
}
_OPENAI_DALLE3_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1792x1024",
"9:16": "1024x1792",
"3:4": "1024x1792",
"4:3": "1792x1024",
}
_OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES = {
"1:1": "1024x1024",
"16:9": "1536x1024",
"9:16": "1024x1536",
"3:4": "1024x1536",
"4:3": "1536x1024",
}
class OpenAIImageGenerationClient(ImageGenerationProvider):
"""OpenAI Images API using an API key (``providers.openai.apiKey``)."""
provider_name = "openai"
missing_key_message = (
"OpenAI API key is not configured. Set providers.openai.apiKey."
)
def _default_base_url(self) -> str:
return "https://api.openai.com/v1"
@staticmethod
def _strip_model_prefix(model: str) -> str:
"""Remove ``openai/`` prefix if present (OpenRouter convention)."""
if model.startswith("openai/") or model.startswith("openai_codex/"):
return model.split("/", 1)[1]
return model
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
if not self.api_key:
raise ImageGenerationError(self.missing_key_message)
if reference_images:
logger.warning(
"DALL-E models do not support reference images; "
"ignoring {} reference image(s) for {}",
len(reference_images),
model,
)
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
**self.extra_headers,
}
clean_model = self._strip_model_prefix(model)
body: dict[str, Any] = {
"model": clean_model,
"prompt": prompt,
}
if not _openai_is_gpt_image_model(clean_model):
body["response_format"] = "b64_json"
body["n"] = 1
size = _openai_size(clean_model, aspect_ratio, image_size)
if size:
body["size"] = size
body.update(self.extra_body)
logger.info("OpenAI Images API request: POST {}/images/generations body={}", self.api_base, body)
response = await self._http_post(
f"{self.api_base}/images/generations",
headers=headers,
body=body,
)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = response.text[:1000]
logger.error("OpenAI Images API error ({}): {}", response.status_code, detail)
raise ImageGenerationError(
f"OpenAI image generation failed (HTTP {response.status_code}): {detail}"
) from exc
payload = response.json()
logger.info("OpenAI Images API response ({}): {}", response.status_code,
{k: v for k, v in payload.items() if k != "data"})
client = self._client
owns_client = client is None
if owns_client:
client = httpx.AsyncClient(timeout=self.timeout)
try:
images = await _openai_images_from_payload(client, payload)
finally:
if owns_client:
await client.aclose()
self._require_images(images, payload)
return GeneratedImageResponse(images=images, content="", raw=payload)
# ---------------------------------------------------------------------------
# OpenAI Codex image generation
# ---------------------------------------------------------------------------
class CodexImageGenerationClient(ImageGenerationProvider):
"""OpenAI image generation via Codex subscription OAuth.
Uses the Codex Responses API with the ``image_generation`` tool
(the same mechanism ChatGPT uses internally). No API key required
the Codex OAuth token from ``oauth_cli_kit`` is used instead.
"""
provider_name = "openai_codex"
missing_key_message = (
"Codex OAuth token is unavailable. "
"Log in with Codex subscription first."
)
def _default_base_url(self) -> str:
return "https://chatgpt.com/backend-api"
def _codex_model(self, model: str) -> str:
"""Strip the ``openai-codex/`` prefix if present."""
if model.startswith(("openai-codex/", "openai_codex/")):
return model.split("/", 1)[1]
return model
async def generate(
self,
*,
prompt: str,
model: str,
reference_images: list[str] | None = None,
aspect_ratio: str | None = None,
image_size: str | None = None,
) -> GeneratedImageResponse:
try:
from oauth_cli_kit import get_token as get_codex_token
except ImportError:
raise ImageGenerationError(self.missing_key_message)
try:
token = await asyncio.to_thread(get_codex_token)
except Exception as exc:
raise ImageGenerationError(self.missing_key_message) from exc
if not token or not token.access:
raise ImageGenerationError(self.missing_key_message)
logger.info(
"Using Codex OAuth token for image generation (account: {})",
token.account_id,
)
if reference_images:
logger.warning(
"Codex image generation does not support reference images; "
"ignoring {} reference image(s)",
len(reference_images),
)
headers = {
"Authorization": f"Bearer {token.access}",
"chatgpt-account-id": token.account_id,
"OpenAI-Beta": "responses=experimental",
"originator": "nanobot",
"User-Agent": "nanobot (python)",
"Content-Type": "application/json",
**self.extra_headers,
}
body: dict[str, Any] = {
"model": self._codex_model(model),
"instructions": "Generate an image based on the user's request.",
"input": [{"role": "user", "content": prompt}],
"tools": [{"type": "image_generation"}],
"tool_choice": "auto",
"stream": True,
"store": False,
}
body.update(self.extra_body)
logger.info("Codex Responses API request: POST {}/codex/responses body={}",
self.api_base, {k: v for k, v in body.items() if k != "input"})
response = await self._http_post(
f"{self.api_base}/codex/responses",
headers=headers,
body=body,
)
try:
response.raise_for_status()
except httpx.HTTPStatusError as exc:
detail = response.text[:1000]
logger.error("Codex Responses API error ({}): {}", response.status_code, detail)
raise ImageGenerationError(
f"Codex image generation failed (HTTP {response.status_code}): {detail}"
) from exc
images, content_text = await _parse_codex_sse_images(response)
raw = {"status": "completed"}
self._require_images(images, raw)
return GeneratedImageResponse(images=images, content=content_text, raw=raw)
def _openai_size(
model: str,
aspect_ratio: str | None,
image_size: str | None,
) -> str:
"""Resolve aspect ratio or image_size to an OpenAI Images API size string."""
sizes, supported_sizes = _openai_size_options(model)
explicit_size = _normalize_openai_image_size(image_size)
if explicit_size and _openai_explicit_size_supported(
explicit_size,
supported_sizes=supported_sizes,
):
return explicit_size
if explicit_size:
logger.warning(
"OpenAI image size '{}' is not supported by {}; using aspect ratio/default size",
explicit_size,
model,
)
if aspect_ratio and aspect_ratio in sizes:
return sizes[aspect_ratio]
return "1024x1024"
def _openai_is_gpt_image_model(model: str) -> bool:
normalized = model.lower()
return normalized.startswith(("gpt-image", "chatgpt-image"))
def _openai_size_options(model: str) -> tuple[dict[str, str], set[str] | None]:
normalized = model.lower()
if normalized.startswith("dall-e-2"):
return _OPENAI_DALLE2_ASPECT_RATIO_SIZES, _OPENAI_DALLE2_SUPPORTED_SIZES
if normalized.startswith("dall-e-3"):
return _OPENAI_DALLE3_ASPECT_RATIO_SIZES, _OPENAI_DALLE3_SUPPORTED_SIZES
if normalized.startswith("gpt-image-2"):
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, None
return _OPENAI_GPT_IMAGE_ASPECT_RATIO_SIZES, _OPENAI_GPT_IMAGE_SUPPORTED_SIZES
def _normalize_openai_image_size(image_size: str | None) -> str | None:
if not image_size:
return None
normalized = image_size.strip().lower()
return normalized or None
def _openai_explicit_size_supported(
size: str,
*,
supported_sizes: set[str] | None,
) -> bool:
if supported_sizes is not None:
return size in supported_sizes
width, sep, height = size.partition("x")
return bool(sep and width.isdecimal() and height.isdecimal())
async def _openai_images_from_payload(
client: httpx.AsyncClient,
payload: dict[str, Any],
) -> list[str]:
"""Extract images from OpenAI Images API response.
Handles both ``b64_json`` (preferred) and ``url`` (downloaded) formats.
"""
images: list[str] = []
for item in payload.get("data") or []:
if not isinstance(item, dict):
continue
b64 = item.get("b64_json")
if isinstance(b64, str) and b64:
images.append(_b64_image_data_url(b64))
continue
url = item.get("url")
if isinstance(url, str) and url:
images.append(await _download_image_data_url(client, url))
return images
def _codex_responses_images_from_payload(payload: dict[str, Any]) -> list[str]:
"""Extract images from Codex Responses API ``image_generation_call`` output."""
images: list[str] = []
for item in payload.get("output") or []:
if not isinstance(item, dict):
continue
if item.get("type") != "image_generation_call":
continue
result = item.get("result")
if isinstance(result, str):
images.append(result if result.startswith("data:image/") else _b64_image_data_url(result))
continue
if isinstance(result, dict):
image_url = result.get("image_url") or result.get("image") or ""
if isinstance(image_url, str):
images.append(image_url if image_url.startswith("data:image/") else _b64_image_data_url(image_url))
return images
async def _parse_codex_sse_images(
response: httpx.Response,
) -> tuple[list[str], str]:
"""Parse a Codex Responses API SSE stream for image generation output.
Returns ``(images, content_text)``.
"""
import json as _json
images: list[str] = []
text_parts: list[str] = []
buffer: list[str] = []
async for line_bytes in response.aiter_lines():
line = line_bytes.strip()
if line == "":
if buffer:
data_lines = []
for bl in buffer:
if bl.startswith("data:"):
data_lines.append(bl[5:].strip())
buffer.clear()
if data_lines:
raw = "".join(data_lines)
if raw == "[DONE]":
break
try:
event = _json.loads(raw)
except Exception:
continue
ev_type = event.get("type", "")
if ev_type in ("error", "response.failed"):
logger.error("Codex SSE failure: {}", raw[:2000])
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
continue
buffer.append(line)
# flush remaining
if buffer:
data_lines = [bl[5:].strip() for bl in buffer if bl.startswith("data:")]
raw = "".join(data_lines)
if raw and raw != "[DONE]":
try:
event = _json.loads(raw)
except Exception:
pass
else:
_collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts)
return images, "".join(text_parts).strip()
def _collect_images_from_sse_event(event: dict[str, Any], images: list[str]) -> None:
if event.get("type") != "response.output_item.done":
return
item = event.get("item") or {}
if item.get("type") != "image_generation_call":
return
result = item.get("result")
if isinstance(result, str):
if result.startswith("data:image/"):
images.append(result)
else:
images.append(_b64_image_data_url(result))
elif isinstance(result, dict):
image_url = result.get("image_url") or result.get("image") or ""
if isinstance(image_url, str):
if image_url.startswith("data:image/"):
images.append(image_url)
else:
images.append(_b64_image_data_url(image_url))
def _collect_text_from_sse_event(event: dict[str, Any], text_parts: list[str]) -> None:
if event.get("type") == "response.output_text.delta":
delta = event.get("delta")
if isinstance(delta, str) and delta:
text_parts.append(delta)
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰) image generation
# ---------------------------------------------------------------------------
@@ -1449,11 +883,8 @@ def _stepfun_images_from_payload(payload: dict[str, Any]) -> list[str]:
# Provider registration
# ---------------------------------------------------------------------------
register_image_gen_provider(AIHubMixImageGenerationClient)
register_image_gen_provider(CodexImageGenerationClient)
register_image_gen_provider(GeminiImageGenerationClient)
register_image_gen_provider(OllamaImageGenerationClient)
register_image_gen_provider(MiniMaxImageGenerationClient)
register_image_gen_provider(OpenAIImageGenerationClient)
register_image_gen_provider(OpenRouterImageGenerationClient)
register_image_gen_provider(AIHubMixImageGenerationClient)
register_image_gen_provider(GeminiImageGenerationClient)
register_image_gen_provider(MiniMaxImageGenerationClient)
register_image_gen_provider(StepFunImageGenerationClient)
+64 -98
View File
@@ -11,7 +11,6 @@ import secrets
import string
import time
import uuid
from collections import deque
from collections.abc import Awaitable, Callable
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
@@ -75,43 +74,41 @@ _THINKING_STYLE_MAP: dict[str, Any] = {
"enable_thinking": lambda on: {"enable_thinking": on},
"reasoning_split": lambda on: {"reasoning_split": on},
}
_GATEWAY_REASONING_STYLE_MAP: dict[str, Any] = {
"reasoning_effort": lambda effort: {"reasoning": {"effort": effort}},
}
_MODEL_THINKING_STYLES: dict[str, str] = {
**dict.fromkeys(_KIMI_THINKING_MODELS, "thinking_type"),
**dict.fromkeys(_MIMO_THINKING_MODELS, "thinking_type"),
}
def _model_slug(model_name: str) -> str:
return model_name.lower().rsplit("/", 1)[-1]
def _is_kimi_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a Kimi thinking-capable model.
Supports two forms:
- Exact match: e.g. kimi-k2.5 / kimi-k2.6 in _KIMI_THINKING_MODELS
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
is checked against _KIMI_THINKING_MODELS
This covers both the native Moonshot provider (bare slug) and
OpenRouter-style names (``"publisher/slug"``).
"""
name = model_name.lower()
if name in _KIMI_THINKING_MODELS:
return True
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
return True
return False
def _model_thinking_style(model_name: str) -> str:
return _MODEL_THINKING_STYLES.get(_model_slug(model_name), "")
def _is_mimo_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a MiMo thinking-capable model.
def _thinking_styles_for(spec: ProviderSpec | None, model_name: str) -> list[str]:
styles: list[str] = []
if spec and spec.thinking_style:
styles.append(spec.thinking_style)
model_style = _model_thinking_style(model_name)
if model_style and model_style not in styles:
styles.append(model_style)
return styles
def _thinking_extra_body(style: str, thinking_enabled: bool) -> dict[str, Any] | None:
builder = _THINKING_STYLE_MAP.get(style)
return builder(thinking_enabled) if builder else None
def _gateway_reasoning_extra_body(style: str, effort: str | None) -> dict[str, Any] | None:
if not effort:
return None
builder = _GATEWAY_REASONING_STYLE_MAP.get(style)
return builder(effort) if builder else None
Mirrors _is_kimi_thinking_model: gateway providers (e.g. OpenRouter
routing ``xiaomi/mimo-v2.5-pro``) have no ``thinking_style`` on their
spec, so the spec-driven branch in _build_kwargs misses them. The
model-name path catches those cases.
"""
name = model_name.lower()
if name in _MIMO_THINKING_MODELS:
return True
if "/" in name and name.rsplit("/", 1)[1] in _MIMO_THINKING_MODELS:
return True
return False
def _openai_compat_timeout_s() -> float:
@@ -464,7 +461,6 @@ class OpenAICompatProvider(LLMProvider):
"""Strip non-standard keys, normalize tool_call IDs."""
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
id_map: dict[str, str] = {}
pending_tool_ids: dict[str, deque[str]] = {}
force_string_content = bool(self._spec and self._spec.name == "deepseek")
def map_id(value: Any) -> Any:
@@ -472,49 +468,15 @@ class OpenAICompatProvider(LLMProvider):
return value
return id_map.setdefault(value, self._normalize_tool_call_id(value))
def unique_tool_id(value: Any, used_ids: set[str], idx: int) -> str:
if isinstance(value, str) and value:
base = map_id(value)
else:
base = _short_tool_id()
if not isinstance(base, str) or not base:
base = _short_tool_id()
if base not in used_ids:
return base
seed = value if isinstance(value, str) and value else base
salt = 1
while True:
candidate = self._normalize_tool_call_id(f"{seed}:{idx}:{salt}")
if isinstance(candidate, str) and candidate not in used_ids:
return candidate
salt += 1
def map_tool_result_id(value: Any) -> Any:
if not isinstance(value, str):
return value
queue = pending_tool_ids.get(value)
if queue:
mapped = queue.popleft()
if not queue:
pending_tool_ids.pop(value, None)
return mapped
return map_id(value)
for clean in sanitized:
if isinstance(clean.get("tool_calls"), list):
normalized = []
used_ids: set[str] = set()
for idx, tc in enumerate(clean["tool_calls"]):
for tc in clean["tool_calls"]:
if not isinstance(tc, dict):
normalized.append(tc)
continue
tc_clean = dict(tc)
raw_id = tc_clean.get("id")
mapped_id = unique_tool_id(raw_id, used_ids, idx)
tc_clean["id"] = mapped_id
used_ids.add(mapped_id)
if isinstance(raw_id, str) and raw_id:
pending_tool_ids.setdefault(raw_id, deque()).append(mapped_id)
tc_clean["id"] = map_id(tc_clean.get("id"))
function = tc_clean.get("function")
if isinstance(function, dict):
function_clean = dict(function)
@@ -532,7 +494,7 @@ class OpenAICompatProvider(LLMProvider):
# that mix non-empty content with tool_calls.
clean["content"] = None
if "tool_call_id" in clean and clean["tool_call_id"]:
clean["tool_call_id"] = map_tool_result_id(clean["tool_call_id"])
clean["tool_call_id"] = map_id(clean["tool_call_id"])
if (
force_string_content
and not (clean.get("role") == "assistant" and clean.get("tool_calls"))
@@ -619,27 +581,39 @@ class OpenAICompatProvider(LLMProvider):
if wire_effort and semantic_effort != "none":
kwargs["reasoning_effort"] = wire_effort
# Only send thinking controls when reasoning_effort is explicit so
# omitting the config preserves each provider's default.
if reasoning_effort is not None:
# Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise.
# The mapping is driven by ProviderSpec.thinking_style so that adding
# a new provider never requires touching this function.
if spec and spec.thinking_style and reasoning_effort is not None:
thinking_enabled = semantic_effort not in ("none", "minimal")
for thinking_style in _thinking_styles_for(spec, model_name):
extra = _thinking_extra_body(thinking_style, thinking_enabled)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
gateway_style = getattr(spec, "gateway_reasoning_style", "") if spec else ""
if gateway_style and _model_thinking_style(model_name):
extra = _gateway_reasoning_extra_body(gateway_style, semantic_effort)
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
# Moonshot rejects requests that carry both 'reasoning_effort'
# and the native 'thinking' param. We already expressed the
# user's intent via the provider-native shape, so drop the
# redundant wire-level kwarg. Only kimi models need this —
# Xiaomi's API accepts both params.
if _model_slug(model_name) in _KIMI_THINKING_MODELS:
kwargs.pop("reasoning_effort", None)
# Model-level thinking injection for Kimi thinking-capable models.
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
# identically to bare names like "kimi-k2.5".
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
thinking_enabled = semantic_effort not in ("none", "minimal")
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
# Model-level thinking injection for MiMo thinking-capable models.
# Same shape as Kimi: gateway providers (OpenRouter, etc.) lack the
# xiaomi_mimo spec's thinking_style, so the spec-driven branch above
# misses them — match by model name to catch "xiaomi/mimo-v2.5-pro"
# and friends. (Direct xiaomi_mimo requests are also covered here;
# both branches write the same payload, so the dict update is a
# safe no-op for already-handled cases.)
if reasoning_effort is not None and _is_mimo_thinking_model(model_name):
thinking_enabled = semantic_effort not in ("none", "minimal")
kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
)
if tools:
kwargs["tools"] = tools
@@ -654,7 +628,8 @@ class OpenAICompatProvider(LLMProvider):
and semantic_effort not in ("none", "minimal")
and (
(spec and spec.thinking_style)
or _model_thinking_style(model_name)
or _is_kimi_thinking_model(model_name)
or _is_mimo_thinking_model(model_name)
)
)
implicit_deepseek_thinking = (
@@ -1122,15 +1097,6 @@ class OpenAICompatProvider(LLMProvider):
if delta:
_accum_legacy_function_call(getattr(delta, "function_call", None))
# Some providers (e.g. Zhipu/GLM) reuse the same tool_call id for
# parallel tool calls in streaming mode. Deduplicate before building
# the response so downstream tool messages don't collide.
_seen_tc_ids: set[str] = set()
for b in tc_bufs.values():
if not b["id"] or b["id"] in _seen_tc_ids:
b["id"] = _short_tool_id()
_seen_tc_ids.add(b["id"])
return LLMResponse(
content="".join(content_parts) or None,
tool_calls=[
@@ -15,7 +15,6 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
"""
system_prompt = ""
input_items: list[dict[str, Any]] = []
used_item_ids: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
@@ -31,19 +30,17 @@ def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str
if role == "assistant":
if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({
"type": "message", "role": "assistant",
"content": [{"type": "output_text", "text": content}],
"status": "completed", "id": message_id,
"status": "completed", "id": f"msg_{idx}",
})
for tool_call in msg.get("tool_calls", []) or []:
fn = tool_call.get("function") or {}
call_id, item_id = split_tool_call_id(tool_call.get("id"))
response_item_id = _unique_item_id(item_id or f"fc_{idx}", used_item_ids)
input_items.append({
"type": "function_call",
"id": response_item_id,
"id": item_id or f"fc_{idx}",
"call_id": call_id or f"call_{idx}",
"name": fn.get("name"),
"arguments": fn.get("arguments") or "{}",
@@ -100,20 +97,6 @@ def convert_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
return converted
def _unique_item_id(item_id: str, used: set[str]) -> str:
"""Return a Responses input item id that is unique within one request."""
if item_id not in used:
used.add(item_id)
return item_id
suffix = 2
while f"{item_id}_{suffix}" in used:
suffix += 1
unique = f"{item_id}_{suffix}"
used.add(unique)
return unique
def split_tool_call_id(tool_call_id: Any) -> tuple[str, str | None]:
"""Split a compound ``call_id|item_id`` string.
+13 -19
View File
@@ -34,7 +34,7 @@ class ProviderSpec:
display_name: str = "" # shown in `nanobot status`
# which provider implementation to use
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "bedrock"
# "openai_compat" | "anthropic" | "azure_openai" | "openai_codex" | "github_copilot" | "xai_oauth" | "bedrock"
backend: str = "openai_compat"
# extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),)
@@ -71,11 +71,6 @@ class ProviderSpec:
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
thinking_style: str = ""
# Gateway-native reasoning control to pair with model-level thinking styles.
# "reasoning_effort" — {"reasoning": {"effort": <none|minimal|...>}}
# (OpenRouter)
gateway_reasoning_style: str = ""
# When True, treat the "reasoning" response field as formal content
# when "content" is empty. Only set this for providers (e.g. StepFun)
# whose API returns the actual answer in "reasoning" instead of "content".
@@ -147,7 +142,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="openrouter",
default_api_base="https://openrouter.ai/api/v1",
supports_prompt_caching=True,
gateway_reasoning_style="reasoning_effort",
),
# Hugging Face Inference Providers: OpenAI-compatible router for chat models.
ProviderSpec(
@@ -199,18 +193,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
default_api_base="https://api.siliconflow.cn/v1",
),
# Novita AI: OpenAI-compatible gateway for hosted model APIs.
ProviderSpec(
name="novita",
keywords=("novita",),
env_key="NOVITA_API_KEY",
display_name="Novita AI",
backend="openai_compat",
is_gateway=True,
detect_by_base_keyword="novita",
default_api_base="https://api.novita.ai/openai",
),
# VolcEngine (火山引擎): OpenAI-compatible gateway, pay-per-use models
ProviderSpec(
name="volcengine",
@@ -309,6 +291,18 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_oauth=True,
supports_max_completion_tokens=True,
),
# xAI Grok OAuth: SuperGrok subscription-backed Responses API provider
ProviderSpec(
name="xai_oauth",
keywords=("xai-oauth", "grok-oauth", "x-ai-oauth", "xai-grok-oauth"),
env_key="",
display_name="xAI Grok OAuth",
backend="xai_oauth",
default_api_base="https://api.x.ai/v1",
strip_model_prefix=True,
is_oauth=True,
supports_max_completion_tokens=True,
),
# DeepSeek: OpenAI-compatible at api.deepseek.com
ProviderSpec(
name="deepseek",
+768
View File
@@ -0,0 +1,768 @@
"""xAI Grok OAuth credential flow and Responses provider."""
from __future__ import annotations
import asyncio
import base64
import json
import os
import secrets
import time
import webbrowser
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass
from hashlib import sha256
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Event, Thread
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
import httpx
from filelock import FileLock
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.openai_responses import consume_sse, convert_messages, convert_tools
DEFAULT_XAI_API_BASE = "https://api.x.ai/v1"
DEFAULT_XAI_AUTH_ISSUER = "https://auth.x.ai"
DEFAULT_XAI_DISCOVERY_URL = f"{DEFAULT_XAI_AUTH_ISSUER}/.well-known/openid-configuration"
DEFAULT_XAI_REDIRECT_URI = "http://127.0.0.1:56121/callback"
DEFAULT_XAI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828"
DEFAULT_XAI_SCOPE = "openid profile email offline_access grok-cli:access api:access"
_SERVICE_NAME = "nanobot.xai_oauth"
_SECRET_USERNAME = "default"
_TOKEN_SKEW_SECONDS = 60
_LOGIN_TIMEOUT_SECONDS = 300
@dataclass(frozen=True)
class XaiOAuthEndpoints:
authorization_endpoint: str
token_endpoint: str
@dataclass(frozen=True)
class XaiOAuthCredential:
access_token: str
refresh_token: str = ""
expires_at: float | None = None
account_id: str | None = None
token_type: str = "Bearer"
api_base: str = DEFAULT_XAI_API_BASE
storage: str = "unknown"
@property
def is_expiring(self) -> bool:
return self.expires_at is not None and self.expires_at <= time.time() + _TOKEN_SKEW_SECONDS
def _nanobot_home() -> Path:
override = os.environ.get("NANOBOT_HOME")
if override:
return Path(override).expanduser()
from nanobot.config.loader import get_config_path
return get_config_path().parent
def _auth_dir() -> Path:
return _nanobot_home() / "auth"
def get_xai_oauth_metadata_path() -> Path:
"""Return the non-secret xAI OAuth metadata path."""
return _auth_dir() / "xai-oauth.json"
def _lock_path() -> Path:
return get_xai_oauth_metadata_path().with_suffix(".lock")
def _write_private_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with suppress(OSError):
path.parent.chmod(0o700)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
with suppress(OSError):
tmp.chmod(0o600)
tmp.replace(path)
with suppress(OSError):
path.chmod(0o600)
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _keyring_set(tokens: dict[str, Any]) -> bool:
try:
import keyring # type: ignore[import-not-found]
keyring.set_password(_SERVICE_NAME, _SECRET_USERNAME, json.dumps(tokens))
return True
except Exception:
return False
def _keyring_get() -> dict[str, Any] | None:
try:
import keyring # type: ignore[import-not-found]
raw = keyring.get_password(_SERVICE_NAME, _SECRET_USERNAME)
except Exception:
return None
if not raw:
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
return None
return payload if isinstance(payload, dict) else None
def _keyring_delete() -> None:
try:
import keyring # type: ignore[import-not-found]
keyring.delete_password(_SERVICE_NAME, _SECRET_USERNAME)
except Exception:
pass
def _token_payload(credential: XaiOAuthCredential) -> dict[str, Any]:
return {
"access_token": credential.access_token,
"refresh_token": credential.refresh_token,
"expires_at": credential.expires_at,
"token_type": credential.token_type,
}
def save_xai_oauth_credential(credential: XaiOAuthCredential) -> XaiOAuthCredential:
"""Persist xAI OAuth tokens, preferring OS keychain storage."""
with FileLock(str(_lock_path())):
tokens = _token_payload(credential)
metadata: dict[str, Any] = {
"provider": "xai_oauth",
"api_base": credential.api_base,
"account_id": credential.account_id,
"expires_at": credential.expires_at,
"updated_at": int(time.time()),
}
if _keyring_set(tokens):
metadata["storage"] = "keyring"
else:
metadata["storage"] = "file"
metadata["tokens"] = tokens
_write_private_json(get_xai_oauth_metadata_path(), metadata)
return XaiOAuthCredential(
access_token=credential.access_token,
refresh_token=credential.refresh_token,
expires_at=credential.expires_at,
account_id=credential.account_id,
token_type=credential.token_type,
api_base=credential.api_base,
storage=str(metadata["storage"]),
)
def load_xai_oauth_credential() -> XaiOAuthCredential | None:
"""Load xAI OAuth credentials from keyring or the private file fallback."""
path = get_xai_oauth_metadata_path()
if not path.exists():
return None
with FileLock(str(_lock_path())):
try:
metadata = _read_json(path)
except (OSError, json.JSONDecodeError):
return None
storage = str(metadata.get("storage") or "file")
tokens = _keyring_get() if storage == "keyring" else metadata.get("tokens")
if not isinstance(tokens, dict):
return None
access_token = str(tokens.get("access_token") or "")
if not access_token:
return None
return XaiOAuthCredential(
access_token=access_token,
refresh_token=str(tokens.get("refresh_token") or ""),
expires_at=_as_float(tokens.get("expires_at") or metadata.get("expires_at")),
account_id=_as_str(metadata.get("account_id")),
token_type=str(tokens.get("token_type") or "Bearer"),
api_base=str(metadata.get("api_base") or DEFAULT_XAI_API_BASE),
storage=storage,
)
def delete_xai_oauth_credentials() -> list[Path]:
"""Delete persisted xAI OAuth credentials and return removed local paths."""
removed: list[Path] = []
path = get_xai_oauth_metadata_path()
lock_path = _lock_path()
with FileLock(str(lock_path)):
_keyring_delete()
try:
path.unlink()
removed.append(path)
except FileNotFoundError:
pass
try:
lock_path.unlink()
except FileNotFoundError:
pass
return removed
def get_xai_oauth_login_status() -> XaiOAuthCredential | None:
return load_xai_oauth_credential()
def pkce_challenge(verifier: str) -> str:
digest = sha256(verifier.encode("ascii")).digest()
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
def _new_pkce_verifier() -> str:
return base64.urlsafe_b64encode(secrets.token_bytes(48)).decode("ascii").rstrip("=")
def build_xai_authorization_url(
endpoints: XaiOAuthEndpoints,
*,
verifier: str,
state: str,
nonce: str | None = None,
redirect_uri: str = DEFAULT_XAI_REDIRECT_URI,
) -> str:
params = {
"response_type": "code",
"client_id": DEFAULT_XAI_CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": DEFAULT_XAI_SCOPE,
"code_challenge": pkce_challenge(verifier),
"code_challenge_method": "S256",
"state": state,
"nonce": nonce or secrets.token_urlsafe(16),
"plan": "generic",
"referrer": "nanobot",
}
return f"{endpoints.authorization_endpoint}?{urlencode(params)}"
def discover_xai_oauth_endpoints() -> XaiOAuthEndpoints:
try:
with httpx.Client(timeout=20.0, follow_redirects=True, trust_env=True) as client:
response = client.get(DEFAULT_XAI_DISCOVERY_URL)
response.raise_for_status()
payload = response.json()
except Exception:
payload = {}
endpoints = XaiOAuthEndpoints(
authorization_endpoint=str(
payload.get("authorization_endpoint")
or f"{DEFAULT_XAI_AUTH_ISSUER}/authorize"
),
token_endpoint=str(
payload.get("token_endpoint")
or f"{DEFAULT_XAI_AUTH_ISSUER}/oauth/token"
),
)
_validate_xai_endpoint(endpoints.authorization_endpoint, "authorization_endpoint")
_validate_xai_endpoint(endpoints.token_endpoint, "token_endpoint")
return endpoints
def _validate_xai_endpoint(url: str, label: str) -> None:
parsed = urlparse(url)
host = parsed.hostname or ""
if parsed.scheme != "https" or not (host == "x.ai" or host.endswith(".x.ai")):
raise RuntimeError(f"Refusing non-xAI OAuth {label}: {url}")
def _parse_callback_value(raw: str) -> tuple[str, str | None]:
raw = raw.strip()
parsed = urlparse(raw)
if parsed.scheme and parsed.netloc:
params = parse_qs(parsed.query)
code = (params.get("code") or [""])[0]
state = (params.get("state") or [None])[0]
if not code:
raise RuntimeError("OAuth callback URL did not contain a code.")
return code, state
if raw.startswith("?") or "=" in raw:
params = parse_qs(raw.lstrip("?"))
code = (params.get("code") or [""])[0]
state = (params.get("state") or [None])[0]
if not code:
raise RuntimeError("OAuth callback query did not contain a code.")
return code, state
if raw:
return raw, None
raise RuntimeError("No OAuth code provided.")
def _decode_jwt_payload(token: str) -> dict[str, Any]:
parts = token.split(".")
if len(parts) < 2:
return {}
data = parts[1] + "=" * (-len(parts[1]) % 4)
try:
decoded = base64.urlsafe_b64decode(data.encode("ascii"))
payload = json.loads(decoded)
except Exception:
return {}
return payload if isinstance(payload, dict) else {}
def _credential_from_token_response(payload: dict[str, Any], previous: XaiOAuthCredential | None = None) -> XaiOAuthCredential:
access_token = str(payload.get("access_token") or "")
if not access_token:
raise RuntimeError("xAI token response did not include an access token.")
claims = _decode_jwt_payload(access_token)
id_claims = _decode_jwt_payload(str(payload.get("id_token") or ""))
expires_at = _as_float(payload.get("expires_at"))
if expires_at is None:
expires_in = _as_float(payload.get("expires_in"))
expires_at = time.time() + expires_in if expires_in else _as_float(claims.get("exp"))
account_id = (
_as_str(id_claims.get("email"))
or _as_str(id_claims.get("preferred_username"))
or _as_str(id_claims.get("sub"))
or _as_str(claims.get("sub"))
or (previous.account_id if previous else None)
)
refresh_token = str(payload.get("refresh_token") or (previous.refresh_token if previous else ""))
return XaiOAuthCredential(
access_token=access_token,
refresh_token=refresh_token,
expires_at=expires_at,
account_id=account_id,
token_type=str(payload.get("token_type") or (previous.token_type if previous else "Bearer")),
api_base=previous.api_base if previous else DEFAULT_XAI_API_BASE,
)
def exchange_xai_oauth_code(
code: str,
*,
verifier: str,
endpoints: XaiOAuthEndpoints | None = None,
redirect_uri: str = DEFAULT_XAI_REDIRECT_URI,
) -> XaiOAuthCredential:
endpoints = endpoints or discover_xai_oauth_endpoints()
challenge = pkce_challenge(verifier)
with httpx.Client(timeout=30.0, follow_redirects=True, trust_env=True) as client:
response = client.post(
endpoints.token_endpoint,
headers={"Accept": "application/json"},
data={
"grant_type": "authorization_code",
"client_id": DEFAULT_XAI_CLIENT_ID,
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
"code_challenge": challenge,
"code_challenge_method": "S256",
},
)
if response.status_code >= 400:
raise RuntimeError(f"xAI token exchange failed: HTTP {response.status_code}: {response.text[:500]}")
return _credential_from_token_response(response.json())
def refresh_xai_oauth_credential(credential: XaiOAuthCredential | None = None) -> XaiOAuthCredential:
credential = credential or load_xai_oauth_credential()
if not credential or not credential.refresh_token:
raise RuntimeError("xAI Grok OAuth is not logged in. Run: nanobot provider login xai-oauth")
endpoints = discover_xai_oauth_endpoints()
with httpx.Client(timeout=30.0, follow_redirects=True, trust_env=True) as client:
response = client.post(
endpoints.token_endpoint,
headers={"Accept": "application/json"},
data={
"grant_type": "refresh_token",
"client_id": DEFAULT_XAI_CLIENT_ID,
"refresh_token": credential.refresh_token,
},
)
if response.status_code >= 400:
raise RuntimeError(f"xAI token refresh failed: HTTP {response.status_code}: {response.text[:500]}")
return save_xai_oauth_credential(_credential_from_token_response(response.json(), previous=credential))
def resolve_xai_oauth_credential(*, force_refresh: bool = False) -> XaiOAuthCredential:
credential = load_xai_oauth_credential()
if not credential:
raise RuntimeError("xAI Grok OAuth is not logged in. Run: nanobot provider login xai-oauth")
if force_refresh or credential.is_expiring:
credential = refresh_xai_oauth_credential(credential)
return credential
def login_xai_oauth_interactive(
print_fn: Callable[[str], None] | None = None,
prompt_fn: Callable[[str], str] | None = None,
open_browser: bool = True,
manual_paste: bool = False,
timeout_seconds: int = _LOGIN_TIMEOUT_SECONDS,
) -> XaiOAuthCredential:
"""Run browser PKCE login and persist xAI OAuth credentials."""
printer = print_fn or print
prompt = prompt_fn or input
endpoints = discover_xai_oauth_endpoints()
verifier = _new_pkce_verifier()
state = secrets.token_urlsafe(24)
nonce = secrets.token_urlsafe(24)
authorize_url = build_xai_authorization_url(
endpoints,
verifier=verifier,
state=state,
nonce=nonce,
)
callback = _LoopbackCallback()
server_started = False if manual_paste else callback.start()
printer(f"Open: {authorize_url}")
if open_browser:
with suppress(Exception):
webbrowser.open(authorize_url)
result: dict[str, str] | None = None
if manual_paste:
printer("Paste the callback URL or xAI fallback code after authorization.")
elif server_started:
try:
result = callback.wait(timeout_seconds)
finally:
callback.stop()
else:
printer("Loopback port 56121 is unavailable; paste the callback URL or xAI fallback code.")
if result:
code = result.get("code") or ""
returned_state = result.get("state")
else:
pasted = prompt("Paste callback URL or fallback code")
code, returned_state = _parse_callback_value(pasted)
if not code:
raise RuntimeError("OAuth login did not return a code.")
if returned_state and returned_state != state:
raise RuntimeError("OAuth state mismatch. Please retry login.")
credential = exchange_xai_oauth_code(code, verifier=verifier, endpoints=endpoints)
return save_xai_oauth_credential(credential)
class _LoopbackCallback:
def __init__(self) -> None:
self._event = Event()
self._result: dict[str, str] = {}
self._server: ThreadingHTTPServer | None = None
self._thread: Thread | None = None
def start(self) -> bool:
owner = self
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802 - stdlib callback name
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
code = (params.get("code") or [""])[0]
state = (params.get("state") or [""])[0]
if parsed.path != "/callback" or not code:
self.send_response(404)
self.end_headers()
return
owner._result = {"code": code, "state": state}
owner._event.set()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(b"<html><body>nanobot xAI OAuth complete. You may close this tab.</body></html>")
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
return
class Server(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
try:
self._server = Server(("127.0.0.1", 56121), Handler)
except OSError:
return False
self._thread = Thread(target=self._server.serve_forever, daemon=True)
self._thread.start()
return True
def wait(self, timeout_seconds: int) -> dict[str, str] | None:
if self._event.wait(timeout_seconds):
return dict(self._result)
return None
def stop(self) -> None:
if self._server:
self._server.shutdown()
self._server.server_close()
if self._thread:
self._thread.join(timeout=1)
def _as_float(value: Any) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _as_str(value: Any) -> str | None:
return value if isinstance(value, str) and value else None
DEFAULT_XAI_MODEL = "xai-oauth/grok-4.3"
class XaiOAuthProvider(LLMProvider):
"""Use a SuperGrok OAuth session to call xAI's Responses API."""
supports_progress_deltas = True
def __init__(self, default_model: str = DEFAULT_XAI_MODEL, config: Any | None = None):
super().__init__(api_key=None, api_base=DEFAULT_XAI_API_BASE)
self.default_model = default_model
self.config = config
async def _call_xai(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
model: str | None,
max_tokens: int,
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
body = _build_xai_responses_body(
messages=messages,
tools=tools,
model=model or self.default_model,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
hosted_x_search=getattr(self.config, "x_search", None),
)
try:
credential = await asyncio.to_thread(resolve_xai_oauth_credential)
try:
content, tool_calls, finish_reason = await _request_xai(
credential,
body,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
except _XaiHTTPError as exc:
if exc.status_code != 401:
raise
credential = await asyncio.to_thread(resolve_xai_oauth_credential, force_refresh=True)
content, tool_calls, finish_reason = await _request_xai(
credential,
body,
on_content_delta=on_content_delta,
on_tool_call_delta=on_tool_call_delta,
)
return LLMResponse(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
except Exception as exc:
msg = f"Error calling xAI Grok OAuth: {exc}"
retry_after = getattr(exc, "retry_after", None) or self._extract_retry_after(msg)
return LLMResponse(content=msg, finish_reason="error", retry_after=retry_after)
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> LLMResponse:
return await self._call_xai(
messages,
tools,
model,
max_tokens,
temperature,
reasoning_effort,
tool_choice,
)
async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> LLMResponse:
_ = on_thinking_delta
return await self._call_xai(
messages,
tools,
model,
max_tokens,
temperature,
reasoning_effort,
tool_choice,
on_content_delta,
on_tool_call_delta,
)
def get_default_model(self) -> str:
return self.default_model
def _strip_model_prefix(model: str) -> str:
for prefix in ("xai-oauth/", "xai_oauth/", "grok-oauth/", "grok_oauth/"):
if model.startswith(prefix):
return model.split("/", 1)[1]
return model
def _build_xai_responses_body(
*,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None,
model: str,
max_tokens: int,
temperature: float,
reasoning_effort: str | None,
tool_choice: str | dict[str, Any] | None,
hosted_x_search: Any | None = None,
) -> dict[str, Any]:
system_prompt, input_items = convert_messages(LLMProvider._sanitize_empty_content(messages))
if system_prompt:
input_items = [
{"role": "system", "content": [{"type": "input_text", "text": system_prompt}]},
*input_items,
]
body: dict[str, Any] = {
"model": _strip_model_prefix(model),
"store": False,
"stream": True,
"input": input_items,
"tool_choice": tool_choice or "auto",
"parallel_tool_calls": True,
}
if max_tokens:
body["max_output_tokens"] = max_tokens
if temperature is not None:
body["temperature"] = temperature
if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort}
converted_tools = convert_tools(tools) if tools else []
hosted_tool = _build_xai_hosted_x_search_tool(hosted_x_search)
if hosted_tool:
converted_tools.append(hosted_tool)
if converted_tools:
body["tools"] = converted_tools
return body
def _clean_x_handles(handles: list[str] | None) -> list[str] | None:
if not handles:
return None
cleaned = [str(handle).strip().lstrip("@") for handle in handles if str(handle).strip()]
return cleaned[:10] or None
def _build_xai_hosted_x_search_tool(config: Any | None) -> dict[str, Any] | None:
if not config or not getattr(config, "enable", False):
return None
allowed = _clean_x_handles(getattr(config, "allowed_x_handles", None))
excluded = _clean_x_handles(getattr(config, "excluded_x_handles", None))
if allowed and excluded:
raise ValueError("providers.xai_oauth.x_search cannot set both allowed_x_handles and excluded_x_handles")
tool: dict[str, Any] = {"type": "x_search"}
if allowed:
tool["allowed_x_handles"] = allowed
if excluded:
tool["excluded_x_handles"] = excluded
if getattr(config, "from_date", None):
tool["from_date"] = config.from_date
if getattr(config, "to_date", None):
tool["to_date"] = config.to_date
if getattr(config, "enable_image_understanding", False):
tool["enable_image_understanding"] = True
if getattr(config, "enable_video_understanding", False):
tool["enable_video_understanding"] = True
return tool
class _XaiHTTPError(RuntimeError):
def __init__(self, message: str, *, status_code: int, retry_after: float | None = None):
super().__init__(message)
self.status_code = status_code
self.retry_after = retry_after
async def _request_xai(
credential: XaiOAuthCredential,
body: dict[str, Any],
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
) -> tuple[str, list[ToolCallRequest], str]:
url = credential.api_base.rstrip("/") + "/responses"
headers = {
"Authorization": f"Bearer {credential.access_token}",
"Accept": "text/event-stream",
"Content-Type": "application/json",
"User-Agent": "nanobot (python)",
}
timeout = httpx.Timeout(120.0, connect=20.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True, trust_env=True) as client:
async with client.stream("POST", url, headers=headers, json=body) as response:
if response.status_code != 200:
raw = await response.aread()
retry_after = LLMProvider._extract_retry_after_from_headers(response.headers)
raise _XaiHTTPError(
_friendly_error(response.status_code, raw.decode("utf-8", "ignore")),
status_code=response.status_code,
retry_after=retry_after,
)
return await consume_sse(response, on_content_delta, on_tool_call_delta)
def _friendly_error(status_code: int, raw: str) -> str:
if status_code == 401:
return "xAI OAuth session expired or was revoked. Run: nanobot provider login xai-oauth"
if status_code == 403:
return (
"xAI accepted the OAuth token, but this account is not entitled for the requested "
"Grok API capability yet. Check the active Grok subscription and selected model."
)
if status_code == 429:
return "xAI Grok subscription quota or rate limit was reached. Please try again later."
return f"HTTP {status_code}: {raw[:500]}"
-17
View File
@@ -165,23 +165,6 @@ class Session:
image_placeholder_text(p) for p in media if isinstance(p, str) and p
)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
cli_apps = message.get("cli_apps")
if role == "user" and isinstance(cli_apps, list) and cli_apps and isinstance(content, str):
cli_lines: list[str] = []
for item in cli_apps[:8]:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip().lower()
if not name:
continue
entry = str(item.get("entry_point") or "unknown").strip() or "unknown"
cli_lines.append(
f"[CLI App Attachment: @{name}; tool=run_cli_app; entry_point={entry}; "
f"skill=skills/cli-app-{name}/SKILL.md]"
)
if cli_lines:
breadcrumbs = "\n".join(cli_lines)
content = f"{content}\n{breadcrumbs}" if content else breadcrumbs
if include_timestamps:
content = self._annotate_message_time(message, content)
if role == "assistant" and isinstance(content, str) and not content.strip():
+4 -8
View File
@@ -1,9 +1,5 @@
# Agent Instructions
## Workspace Guidance
Use this file for project-specific preferences, recurring workflow conventions, and instructions you want the agent to remember for this workspace. Keep durable facts about the user in `USER.md`, personality/style guidance in `SOUL.md`, and long-term memory in `memory/MEMORY.md`.
## Scheduled Reminders
Before scheduling reminders, check available skills and follow skill guidance first.
@@ -14,10 +10,10 @@ Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegr
## Heartbeat Tasks
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks.
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
- Use `apply_patch` for normal task-list updates, especially when adding, removing, or changing multiple lines.
- Use `edit_file` only for small exact replacements copied from the current `HEARTBEAT.md`.
- Use `write_file` for first creation or intentional full-file rewrites.
- **Add**: `edit_file` to append new tasks
- **Remove**: `edit_file` to delete completed tasks
- **Rewrite**: `write_file` to replace all tasks
When the user asks for a recurring/periodic task, update `HEARTBEAT.md` instead of creating a one-time cron reminder.
+28
View File
@@ -0,0 +1,28 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling.
This file documents non-obvious constraints and usage patterns.
## exec — Safety Limits
- Commands have a configurable timeout (default 60s)
- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.)
- Output is truncated at 10,000 characters
- `restrictToWorkspace` config can limit file access to the workspace
## grep — Content Search
- Use `grep` to search file contents inside the workspace
- Default behavior returns only matching file paths (`output_mode="files_with_matches"`)
- Supports optional `glob` filtering (e.g. `glob="*.py"`) plus `context_before` / `context_after`
- Supports `type="py"`, `type="ts"`, `type="md"` and similar shorthand filters
- Use `fixed_strings=true` for literal keywords containing regex characters
- Use `output_mode="files_with_matches"` to get only matching file paths
- Use `output_mode="count"` to size a search before reading full matches
- Use `head_limit` and `offset` to page across results
- Prefer this over `exec` for code and history searches
- Binary or oversized files may be skipped to keep results readable
## cron — Scheduled Reminders
- Please refer to cron skill for usage.
-67
View File
@@ -1,67 +0,0 @@
# Tool Usage Notes
Tool signatures are provided automatically via function calling. This section
documents the general tool contract and non-obvious usage patterns.
## General Tool Contract
- Use the narrowest structured tool that directly matches the task.
- Use read-only discovery before writes when state is uncertain.
- Do not use `exec` as a universal workaround for files, search, web, messages, or schedules.
- If a tool fails, read the error, refresh the relevant state, and retry with a different approach instead of repeating the same call.
- After meaningful changes, verify with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output.
- Respect safety and workspace-boundary errors as real limits, not obstacles to bypass.
## Discovery and Reading
- Use `find_files` or `list_dir` to locate workspace paths before `read_file` when a path is uncertain.
- Use `grep` for content search inside the workspace; prefer it over shell grep for ordinary searches.
- `grep` defaults to `output_mode="files_with_matches"`; use `output_mode="content"` for matching lines with context.
- Use `fixed_strings=true` for literal keywords containing regex characters.
- Use `output_mode="count"` to size a broad search before reading full matches.
- Use `head_limit` and `offset` to page across large result sets.
- Binary or oversized files may be skipped to keep results readable.
## File and Coding Workflows
- For code or config changes, the default loop is: locate (`find_files`/`grep`), inspect (`read_file`), edit (`apply_patch`), then verify (`exec` or re-read).
- Use `apply_patch` as the default code editing tool, especially for multi-file changes, structural edits, generated code, moves, adds, or deletes.
- Use `apply_patch dry_run=true` when the patch is uncertain and you want validation plus a change summary before writing.
- Use `edit_file` only for small exact replacements in one file, with `old_text` copied from `read_file`; add `occurrence`, `line_hint`, or `expected_replacements` when ambiguity matters.
- Use `write_file` for new files or intentional full-file rewrites, not routine partial edits.
- If `apply_patch` or `edit_file` fails, re-read with `force=true`, narrow the context, and try a smaller patch rather than switching to shell `sed` or `echo`.
## Process Execution
- Use `exec` for tests, builds, package commands, git commands, and other process execution.
- Prefer dedicated file/search tools over `cat`, shell `find`, shell `grep`, `sed`, or `echo` for ordinary workspace inspection and edits.
- Use non-interactive flags such as `-y` or `--yes` when available.
- Commands have a configurable timeout (default 60s), dangerous commands are blocked, and output is truncated.
- For long-running or interactive commands, pass `yield_time_ms`; if the process keeps running, continue with `write_stdin`.
- Use `write_stdin` to poll, provide stdin, close stdin, wait for expected output with `wait_for`, or terminate an existing exec session.
- Use `list_exec_sessions` to recover active session IDs after context shifts.
## CLI App Attachments
- When Runtime Context lists a `CLI App Attachment` or `CLI App Mention`, treat the `@name` as an app capability the user intentionally attached to the current turn.
- If the task may need app-specific behavior, read the listed skill first, then call `run_cli_app` with that `name`.
- Do not run an attached CLI app through shell or generic process tools unless the user explicitly asks for that lower-level path.
- If the app CLI is missing, lacks local desktop/app/API prerequisites, or cannot complete the requested action, explain that concrete blocker and what was attempted.
## Web and External Information
- Use web tools when the user asks for current information, a specific URL, or information likely to have changed.
- Use `web_search` to find sources and `web_fetch` for a specific page or result that needs closer reading.
- Do not invent freshness-sensitive facts when tools can verify them.
## Messaging and Media
- Use `message` to send content or local media to the user/channel.
- `read_file` only reads content for your analysis; it does not deliver a file to the user.
- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text.
## Scheduling and Background Work
- Use `cron` for scheduled reminders or recurring jobs; do not run `nanobot cron` through `exec`.
- For heartbeat tasks, update `HEARTBEAT.md` according to the agent instructions.
- Do not write reminders only to memory files when the user expects an actual notification.
+84 -265
View File
@@ -3,13 +3,15 @@
from __future__ import annotations
import difflib
import json
import re
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Awaitable, Callable
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "apply_patch"})
TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
_LIVE_EMIT_INTERVAL_S = 0.18
_LIVE_EMIT_LINE_STEP = 24
@@ -152,108 +154,19 @@ def prepare_file_edit_tracker(
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,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[FileEditTracker]:
if not is_file_edit_tool(tool_name):
return []
paths = resolve_file_edit_paths(tool_name, tool, workspace, params)
trackers: list[FileEditTracker] = []
seen: set[Path] = set()
for path in paths:
try:
resolved = path.resolve()
except Exception:
resolved = path
if resolved in seen:
continue
seen.add(resolved)
return None
path = resolve_file_edit_path(tool, workspace, params)
if path is None:
return None
before = read_file_snapshot(path)
trackers.append(FileEditTracker(
return FileEditTracker(
call_id=str(call_id or ""),
tool=tool_name,
path=path,
display_path=display_file_edit_path(path, workspace),
before=before,
))
return trackers
def resolve_file_edit_paths(
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[Path]:
if tool_name == "apply_patch":
return _resolve_apply_patch_paths(tool, workspace, params)
path = resolve_file_edit_path(tool, workspace, params)
if path is None:
return []
return [path]
def _resolve_apply_patch_paths(
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> list[Path]:
if not isinstance(params, dict):
return []
edits = params.get("edits")
if not isinstance(edits, list) or not edits:
return []
if params.get("dry_run") is True:
return []
resolved: list[Path] = []
seen: set[Path] = set()
for edit in edits:
if not isinstance(edit, dict):
continue
raw_path = edit.get("path")
if not isinstance(raw_path, str) or not raw_path.strip():
continue
path = _resolve_raw_file_edit_path(tool, workspace, raw_path)
if path is not None and path not in seen:
seen.add(path)
resolved.append(path)
return resolved
def _resolve_raw_file_edit_path(
tool: Any,
workspace: Path | None,
raw_path: str,
) -> Path | None:
resolver = getattr(tool, "_resolve", None)
if callable(resolver):
try:
resolved = resolver(raw_path)
if isinstance(resolved, Path):
return resolved
if resolved:
return Path(resolved)
except Exception:
return None
if workspace is None:
return Path(raw_path).expanduser().resolve()
return (workspace / raw_path).expanduser().resolve()
)
def build_file_edit_start_event(
@@ -391,9 +304,6 @@ class StreamingFileEditTracker:
self._states[key] = state
state.apply_delta(payload)
if state.name == "apply_patch":
await self._update_apply_patch(state)
return
if state.name not in {"write_file", "edit_file"}:
return
if state.path is None:
@@ -433,80 +343,10 @@ class StreamingFileEditTracker:
deleted=deleted,
)])
async def _update_apply_patch(self, state: _StreamingFileEditState) -> None:
if _json_bool_true(state.arguments, "dry_run"):
return
tool = self._tools.get("apply_patch") if hasattr(self._tools, "get") else None
events: list[dict[str, Any]] = []
now = time.monotonic()
path_matches = list(re.finditer(r'"path"\s*:\s*"([^"]+)"', state.arguments))
if not path_matches:
return
for i, m in enumerate(path_matches):
raw_path = m.group(1)
path = _resolve_raw_file_edit_path(tool, self._workspace, raw_path)
if path is None:
continue
segment_start = m.start()
segment_end = path_matches[i + 1].start() if i + 1 < len(path_matches) else len(state.arguments)
segment = state.arguments[segment_start:segment_end]
action_match = re.search(r'"action"\s*:\s*"(replace|add|delete)"', segment)
action = action_match.group(1) if action_match else "replace"
old_text = _extract_json_string_prefix(segment, "old_text") or ""
new_text = _extract_json_string_prefix(segment, "new_text") or ""
added = _text_line_count(new_text) if action in ("replace", "add") else 0
deleted = _text_line_count(old_text) if action in ("replace", "delete") else 0
delete_file = action == "delete"
file_state = state.patch_files.get(raw_path)
if file_state is None:
tracker = FileEditTracker(
call_id=state.call_id or state.key,
tool="apply_patch",
path=path,
display_path=display_file_edit_path(path, self._workspace),
before=read_file_snapshot(path),
)
file_state = _StreamingPatchFileState(tracker=tracker)
state.patch_files[raw_path] = file_state
if delete_file and added == 0 and deleted == 0 and file_state.tracker.before.countable:
deleted = _text_line_count(file_state.tracker.before.text or "")
if not file_state.should_emit(added, deleted, now):
continue
file_state.mark_emitted(added, deleted, now)
events.append(build_file_edit_live_event(
file_state.tracker,
added=added,
deleted=deleted,
))
if events:
await self._emit(events)
async def flush(self) -> None:
events: list[dict[str, Any]] = []
now = time.monotonic()
for state in self._states.values():
for file_state in state.patch_files.values():
added, deleted = file_state.last_added, file_state.last_deleted
if not file_state.emitted_once:
continue
if (
file_state.last_emitted_added == added
and file_state.last_emitted_deleted == deleted
):
continue
file_state.mark_emitted(added, deleted, now)
events.append(build_file_edit_live_event(
file_state.tracker,
added=added,
deleted=deleted,
))
if state.tracker is None:
continue
added, deleted = state.live_diff_counts()
@@ -527,14 +367,12 @@ class StreamingFileEditTracker:
def apply_final_call_ids(self, final_tool_calls: list[Any]) -> None:
"""Keep final start/end events keyed to any earlier streamed placeholder."""
used_canonicals: set[str] = set()
for tool_call in final_tool_calls:
canonical = self.canonical_call_id_for(tool_call)
if canonical and canonical not in used_canonicals:
if canonical:
try:
tool_call.id = canonical
used_canonicals.add(canonical)
except (AttributeError, TypeError):
except Exception:
pass
def canonical_call_id_for(self, tool_call: Any) -> str | None:
@@ -551,10 +389,6 @@ class StreamingFileEditTracker:
"""Mark streamed edits as failed when no final tool call will run."""
events: list[dict[str, Any]] = []
for state in self._states.values():
for file_state in state.patch_files.values():
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
continue
events.append(build_file_edit_error_event(file_state.tracker, error))
if state.tracker is None:
continue
if any(state.matches_final_tool_call(tool_call) for tool_call in final_tool_calls):
@@ -658,39 +492,6 @@ class _StreamingJsonStringField:
self.last_char_cr = False
@dataclass(slots=True)
class _StreamingPatchFileState:
tracker: FileEditTracker
emitted_once: bool = False
last_emitted_added: int = -1
last_emitted_deleted: int = -1
last_emit_at: float = 0.0
last_added: int = 0
last_deleted: int = 0
def should_emit(self, added: int, deleted: int, now: float) -> bool:
self.last_added = added
self.last_deleted = deleted
if not self.emitted_once:
return True
if added == self.last_emitted_added and deleted == self.last_emitted_deleted:
return False
if max(
abs(added - self.last_emitted_added),
abs(deleted - self.last_emitted_deleted),
) >= _LIVE_EMIT_LINE_STEP:
return True
return now - self.last_emit_at >= _LIVE_EMIT_INTERVAL_S
def mark_emitted(self, added: int, deleted: int, now: float) -> None:
self.emitted_once = True
self.last_added = added
self.last_deleted = deleted
self.last_emitted_added = added
self.last_emitted_deleted = deleted
self.last_emit_at = now
@dataclass(slots=True)
class _StreamingFileEditState:
key: str
@@ -708,7 +509,6 @@ class _StreamingFileEditState:
new_text: _StreamingJsonStringField = field(
default_factory=lambda: _StreamingJsonStringField("new_text")
)
patch_files: dict[str, _StreamingPatchFileState] = field(default_factory=dict)
emitted_once: bool = False
last_emitted_added: int = -1
last_emitted_deleted: int = -1
@@ -731,7 +531,6 @@ class _StreamingFileEditState:
self.content.reset()
self.old_text.reset()
self.new_text.reset()
self.patch_files.clear()
return
delta = payload.get("arguments_delta")
if isinstance(delta, str) and delta:
@@ -791,14 +590,6 @@ class _StreamingFileEditState:
name = getattr(tool_call, "name", None)
if name != self.name:
return False
if self.name == "apply_patch":
arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict):
return False
edits = arguments.get("edits")
if not isinstance(edits, list):
return False
return '"edits"' in self.arguments
arguments = getattr(tool_call, "arguments", None)
if not isinstance(arguments, dict):
return False
@@ -821,51 +612,6 @@ def _stream_key(payload: dict[str, Any]) -> str:
return ""
def _json_bool_true(source: str, key: str) -> bool:
return re.search(rf'"{re.escape(key)}"\s*:\s*true\b', source) is not None
def _extract_json_string_prefix(source: str, key: str) -> str | None:
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
if match is None:
return None
out: list[str] = []
i = match.end()
escape = False
while i < len(source):
ch = source[i]
if escape:
escape = False
if ch == "n":
out.append("\n")
elif ch == "r":
out.append("\r")
elif ch == "t":
out.append("\t")
elif ch == "u":
digits = source[i + 1:i + 5]
if len(digits) < 4:
break
try:
out.append(chr(int(digits, 16)))
except ValueError:
break
i += 4
else:
out.append(ch)
i += 1
continue
if ch == "\\":
escape = True
i += 1
continue
if ch == '"':
return "".join(out)
out.append(ch)
i += 1
return "".join(out)
def _extract_complete_json_string(source: str, key: str) -> str | None:
match = re.search(rf'"{re.escape(key)}"\s*:\s*"', source)
if match is None:
@@ -958,4 +704,77 @@ def _predict_after_text(
return before_text.replace(old_text, new_text)
return before_text.replace(old_text, new_text, 1)
return None
if tool_name == "notebook_edit":
return _predict_notebook_after_text(params, before_text)
return None
def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
try:
nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
except Exception:
return None
cells = nb.get("cells")
if not isinstance(cells, list):
return None
try:
cell_index = int(params.get("cell_index", 0))
except (TypeError, ValueError):
return None
new_source = params.get("new_source")
source = new_source if isinstance(new_source, str) else ""
cell_type = (
params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
)
mode = (
params.get("edit_mode")
if params.get("edit_mode") in ("replace", "insert", "delete")
else "replace"
)
if mode == "delete":
if 0 <= cell_index < len(cells):
cells.pop(cell_index)
else:
return None
elif mode == "insert":
insert_at = min(max(cell_index + 1, 0), len(cells))
cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
else:
if not (0 <= cell_index < len(cells)):
return None
cell = cells[cell_index]
if not isinstance(cell, dict):
return None
cell["source"] = source
cell["cell_type"] = cell_type
if cell_type == "code":
cell.setdefault("outputs", [])
cell.setdefault("execution_count", None)
else:
cell.pop("outputs", None)
cell.pop("execution_count", None)
nb["cells"] = cells
try:
return json.dumps(nb, indent=1, ensure_ascii=False)
except Exception:
return None
def _empty_notebook() -> dict[str, Any]:
return {
"nbformat": 4,
"nbformat_minor": 5,
"metadata": {
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
"language_info": {"name": "python"},
},
"cells": [],
}
def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
if cell_type == "code":
cell["outputs"] = []
cell["execution_count"] = None
return cell
+2 -3
View File
@@ -576,7 +576,7 @@ def build_status_content(
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
"""Sync bundled templates to workspace. Creates missing files without overwriting user files."""
"""Sync bundled templates to workspace. Only creates missing files."""
from importlib.resources import files as pkg_files
try:
@@ -589,11 +589,10 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]
added: list[str] = []
def _write(src, dest: Path):
content = src.read_text(encoding="utf-8") if src else ""
if dest.exists():
return
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(content, encoding="utf-8")
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
added.append(str(dest.relative_to(workspace)))
for item in tpl.iterdir():
-4
View File
@@ -11,10 +11,8 @@ _TOOL_FORMATS: dict[str, tuple[list[str], str, bool, bool]] = {
"read_file": (["path", "file_path"], "read {}", True, False),
"write_file": (["path", "file_path"], "write {}", True, False),
"edit": (["file_path", "path"], "edit {}", True, False),
"find_files": (["query", "glob", "path"], "find {}", False, False),
"grep": (["pattern"], 'grep "{}"', False, False),
"exec": (["command"], "$ {}", False, True),
"list_exec_sessions": ([], "exec sessions", False, False),
"web_search": (["query"], 'search "{}"', False, False),
"web_fetch": (["url"], "fetch {}", True, False),
"list_dir": (["path"], "ls {}", True, False),
@@ -83,8 +81,6 @@ def _extract_arg(tc, key_args: list[str]) -> str | None:
def _fmt_known(tc, fmt: tuple, max_length: int = 40) -> str:
"""Format a registered tool using its template."""
if not fmt[0] and "{}" not in fmt[1]:
return fmt[1]
val = _extract_arg(tc, fmt[0])
if val is None:
return tc.name
-93
View File
@@ -1,93 +0,0 @@
"""CLI Apps helpers for the WebUI HTTP and message surfaces."""
from __future__ import annotations
import re
from typing import Any
from nanobot.cli_apps import CliAppError, CliAppManager, CliAppsRuntimeConfig
from nanobot.config.loader import load_config
QueryParams = dict[str, list[str]]
_CLI_APP_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$", re.IGNORECASE)
_CLI_APP_ATTACHMENT_KEYS = (
"name",
"display_name",
"category",
"entry_point",
"logo_url",
"brand_color",
)
def _clip_ws_string(value: Any, limit: int = 240) -> str | None:
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
return text[:limit]
def normalize_cli_app_mentions(raw: Any) -> list[dict[str, str]]:
"""Sanitize structured CLI app mentions sent by the WebUI."""
if not isinstance(raw, list):
return []
out: list[dict[str, str]] = []
seen: set[str] = set()
for item in raw[:8]:
if not isinstance(item, dict):
continue
name = _clip_ws_string(item.get("name"), 64)
if not name or _CLI_APP_NAME_RE.match(name) is None:
continue
key = name.lower()
if key in seen:
continue
seen.add(key)
row: dict[str, str] = {"name": key}
for field in _CLI_APP_ATTACHMENT_KEYS[1:]:
value = _clip_ws_string(item.get(field), 512 if field == "logo_url" else 160)
if value:
row[field] = value
out.append(row)
return out
def _query_first(query: QueryParams, key: str) -> str | None:
values = query.get(key)
return values[0] if values else None
def _manager() -> CliAppManager:
config = load_config()
cli_cfg = config.tools.cli_apps
return CliAppManager(
workspace=config.workspace_path,
runtime=CliAppsRuntimeConfig(
install_timeout=cli_cfg.install_timeout,
run_timeout=cli_cfg.run_timeout,
catalog_ttl_seconds=cli_cfg.catalog_ttl_seconds,
),
)
def cli_apps_payload() -> dict[str, Any]:
return _manager().payload()
def cli_apps_action(action: str, query: QueryParams) -> dict[str, Any]:
name = (_query_first(query, "name") or "").strip()
if not name:
raise CliAppError("missing CLI app name")
manager = _manager()
if action == "install":
return manager.install(name)
if action == "update":
return manager.update(name)
if action == "uninstall":
return manager.uninstall(name)
if action == "test":
return manager.test(name)
raise CliAppError(f"unknown CLI app action '{action}'", status=404)
-4
View File
@@ -73,16 +73,12 @@ def _mask_secret_hint(secret: str | None) -> str | None:
def _provider_requires_api_key(spec: Any) -> bool:
if spec.backend == "azure_openai":
return True
if spec.is_oauth:
return False
if spec.is_local or spec.is_direct:
return False
return True
def _provider_configured_for_settings(spec: Any, provider_config: Any) -> bool:
if spec.is_oauth:
return True
if _provider_requires_api_key(spec):
return bool(provider_config.api_key)
return bool(
+3 -91
View File
@@ -116,55 +116,6 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
return lines
_PHASE_RANK = {"start": 1, "end": 2, "error": 3}
def _normalize_tool_events(events: Any) -> list[dict[str, Any]]:
if not isinstance(events, list):
return []
out: list[dict[str, Any]] = []
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") not in {"start", "end", "error"}:
continue
if not isinstance(event.get("name"), str):
fn = event.get("function")
if not (isinstance(fn, dict) and isinstance(fn.get("name"), str)):
continue
out.append(dict(event))
return out
def _tool_event_key(event: dict[str, Any]) -> str:
call_id = event.get("call_id")
if isinstance(call_id, str) and call_id:
return f"call:{call_id}"
return _format_tool_call_trace(event) or json.dumps(event, sort_keys=True, ensure_ascii=False)
def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not isinstance(previous, list) or not previous:
return incoming
if not incoming:
return [dict(event) for event in previous if isinstance(event, dict)]
merged = [dict(event) for event in previous if isinstance(event, dict)]
index_by_key = {_tool_event_key(event): idx for idx, event in enumerate(merged)}
for event in incoming:
key = _tool_event_key(event)
existing_index = index_by_key.get(key)
if existing_index is None:
index_by_key[key] = len(merged)
merged.append(event)
continue
existing = merged[existing_index]
incoming_rank = _PHASE_RANK.get(str(event.get("phase")), 0)
existing_rank = _PHASE_RANK.get(str(existing.get("phase")), 0)
if incoming_rank >= existing_rank:
merged[existing_index] = {**existing, **event}
return merged
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
@@ -185,7 +136,6 @@ def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
) -> list[dict[str, Any]]:
"""Fold JSONL records into ``UIMessage``-shaped dicts for the WebUI.
@@ -455,9 +405,6 @@ def replay_transcript_to_ui_messages(
row["media"] = media_att
if all(m.get("kind") == "image" for m in media_att):
row["images"] = [{"url": m.get("url"), "name": m.get("name")} for m in media_att]
cli_apps = rec.get("cli_apps")
if isinstance(cli_apps, list) and cli_apps:
row["cliApps"] = [dict(app) for app in cli_apps if isinstance(app, dict)]
messages.append(row)
continue
@@ -502,24 +449,6 @@ def replay_transcript_to_ui_messages(
buffer_message_id = None
buffer_parts = []
continue
final_text = rec.get("text")
if isinstance(final_text, str):
if buffer_message_id is None:
buffer_message_id = _new_id("buf", idx)
messages.append(
{
"id": buffer_message_id,
"role": "assistant",
"content": final_text,
"isStreaming": True,
"createdAt": _ts_base + idx,
},
)
else:
for i, m in enumerate(messages):
if m.get("id") == buffer_message_id:
messages[i] = {**m, "content": final_text, "isStreaming": True}
break
buffer_message_id = None
buffer_parts = []
continue
@@ -557,7 +486,6 @@ def replay_transcript_to_ui_messages(
close_reasoning(messages)
continue
if kind in ("tool_hint", "progress"):
structured_events = _normalize_tool_events(rec.get("tool_events"))
structured = tool_trace_lines_from_events(rec.get("tool_events"))
text = rec.get("text")
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
@@ -574,7 +502,7 @@ def replay_transcript_to_ui_messages(
prev_traces = list(last.get("traces") or [last.get("content")])
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added and not structured_events:
if not added:
continue
else:
merged_traces = prev_traces + trace_lines
@@ -582,9 +510,6 @@ def replay_transcript_to_ui_messages(
**last,
"traces": merged_traces,
"content": merged_traces[-1],
"toolEvents": _merge_tool_events(last.get("toolEvents"), structured_events)
if structured_events
else last.get("toolEvents"),
"activitySegmentId": last.get("activitySegmentId") or segment,
}
messages[-1] = merged
@@ -596,7 +521,6 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
**({"toolEvents": structured_events} if structured_events else {}),
"activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
@@ -645,14 +569,7 @@ def replay_transcript_to_ui_messages(
buffer_parts = []
continue
for i, m in enumerate(messages):
if (
augment_assistant_text is not None
and m.get("role") == "assistant"
and m.get("kind") != "trace"
and isinstance(m.get("content"), str)
):
messages[i] = {**m, "content": augment_assistant_text(m["content"])}
for m in messages:
m.pop("isStreaming", None)
m.pop("reasoningStreaming", None)
return messages
@@ -662,17 +579,12 @@ def build_webui_thread_response(
session_key: str,
*,
augment_user_media: Callable[[list[str]], list[dict[str, Any]]] | None = None,
augment_assistant_text: Callable[[str], str] | None = None,
) -> dict[str, Any] | None:
"""Return a payload compatible with ``WebuiThreadPersistedPayload``."""
lines = read_transcript_lines(session_key)
if not lines:
return None
msgs = replay_transcript_to_ui_messages(
lines,
augment_user_media=augment_user_media,
augment_assistant_text=augment_assistant_text,
)
msgs = replay_transcript_to_ui_messages(lines, augment_user_media=augment_user_media)
return {
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
"sessionKey": session_key,
+1
View File
@@ -61,6 +61,7 @@ dependencies = [
"openpyxl>=3.1.0,<4.0.0",
"python-pptx>=1.0.0,<2.0.0",
"filelock>=3.25.2",
"keyring>=25.0.0,<26.0.0",
"boto3>=1.43.0",
]
-53
View File
@@ -139,13 +139,6 @@ class TestLoadBootstrapFiles:
for name in ContextBuilder.BOOTSTRAP_FILES:
assert f"## {name}" in result
def test_legacy_tools_md_is_not_bootstrapped(self, tmp_path):
(tmp_path / "TOOLS.md").write_text("workspace tool notes", encoding="utf-8")
builder = _builder(tmp_path)
result = builder._load_bootstrap_files()
assert "TOOLS.md" not in result
assert "workspace tool notes" not in result
def test_utf8_content(self, tmp_path):
(tmp_path / "AGENTS.md").write_text("用中文回复", encoding="utf-8")
builder = _builder(tmp_path)
@@ -178,37 +171,6 @@ class TestIsTemplateContent:
assert ContextBuilder._is_template_content("totally different", "memory/MEMORY.md") is False
# ---------------------------------------------------------------------------
# Bundled bootstrap templates
# ---------------------------------------------------------------------------
class TestBundledToolContract:
def test_tool_contract_balances_general_and_coding_workflows(self):
from importlib.resources import files as pkg_files
tpl = pkg_files("nanobot") / "templates" / "agent" / "tool_contract.md"
content = tpl.read_text(encoding="utf-8")
assert "## General Tool Contract" in content
assert "Use the narrowest structured tool" in content
assert "Do not use `exec` as a universal workaround" in content
assert "## File and Coding Workflows" in content
assert "apply_patch" in content
assert "## Web and External Information" in content
assert "## Messaging and Media" in content
assert "## Scheduling and Background Work" in content
assert "pure coding" not in content.lower()
def test_tool_contract_is_injected_without_workspace_file(self, tmp_path):
builder = _builder(tmp_path)
prompt = builder.build_system_prompt()
assert "# Tool Usage Notes" in prompt
assert "## General Tool Contract" in prompt
assert "Do not use `exec` as a universal workaround" in prompt
# ---------------------------------------------------------------------------
# _build_user_content
# ---------------------------------------------------------------------------
@@ -362,21 +324,6 @@ class TestBuildMessages:
assert "Other chat goal." not in str(without_goal[-1]["content"])
assert "Goal (active):" not in str(without_goal[-1]["content"])
def test_current_runtime_lines_are_injected(self, tmp_path):
builder = _builder(tmp_path)
messages = builder.build_messages(
[],
"please use @zoom tonight",
current_runtime_lines=[
"CLI App Attachment: @zoom (installed; tool=run_cli_app; entry_point=cli-anything-zoom).",
],
)
user_msg = str(messages[-1]["content"])
assert "CLI App Attachment: @zoom" in user_msg
assert "tool=run_cli_app" in user_msg
assert "entry_point=cli-anything-zoom" in user_msg
def test_consecutive_same_role_merged(self, tmp_path):
builder = _builder(tmp_path)
history = [{"role": "user", "content": "previous user message"}]
-20
View File
@@ -346,26 +346,6 @@ class TestSyncWorkspaceTemplates:
content = (workspace / "AGENTS.md").read_text()
assert content == "existing content"
def test_does_not_create_tools_md(self, tmp_path):
"""Tool contract is injected internally, not copied into user workspaces."""
workspace = tmp_path / "workspace"
added = sync_workspace_templates(workspace, silent=True)
assert "TOOLS.md" not in added
assert not (workspace / "TOOLS.md").exists()
def test_preserves_existing_tools_md_without_overwriting(self, tmp_path):
"""Legacy user workspaces may have TOOLS.md; sync should leave it untouched."""
workspace = tmp_path / "workspace"
workspace.mkdir(parents=True)
tools_path = workspace / "TOOLS.md"
tools_path.write_text("custom tool notes", encoding="utf-8")
sync_workspace_templates(workspace, silent=True)
assert tools_path.read_text(encoding="utf-8") == "custom tool notes"
def test_creates_memory_directory(self, tmp_path):
"""Should create memory directory structure."""
workspace = tmp_path / "workspace"
@@ -359,31 +359,6 @@ def test_get_history_synthesizes_breadcrumb_for_image_only_turn():
assert history[0] == {"role": "user", "content": "[image: /m/pic.png]"}
def test_get_history_synthesizes_cli_app_attachment_breadcrumb():
session = Session(key="test:cli-app")
session.messages.append(
{
"role": "user",
"content": "please use @drawio",
"cli_apps": [{
"name": "drawio",
"entry_point": "cli-anything-drawio",
}],
}
)
history = session.get_history(max_messages=500)
assert history == [{
"role": "user",
"content": (
"please use @drawio\n"
"[CLI App Attachment: @drawio; tool=run_cli_app; "
"entry_point=cli-anything-drawio; skill=skills/cli-app-drawio/SKILL.md]"
),
}]
def test_get_history_ignores_media_kwarg_on_non_user_rows():
"""``media`` only ever appears on user entries in practice, but the
synthesizer must be defensive: assistants / tools with list content
File diff suppressed because it is too large Load Diff
-525
View File
@@ -1,525 +0,0 @@
"""Unit tests for the Signal markdown → plain text + textStyle converter."""
from nanobot.channels.signal import _markdown_to_signal, _partition_styles
from nanobot.utils.helpers import split_message
def _utf16_len(s: str) -> int:
return len(s.encode("utf-16-le")) // 2
def styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Return a dict mapping each styled substring to its style list."""
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = plain[start : start + length]
result.setdefault(span, []).append(style)
return result
def utf16_styles_for(plain: str, text_styles: list[str]) -> dict[str, list[str]]:
"""Like styles_for, but slices `plain` using UTF-16 offsets (Signal's units)."""
encoded = plain.encode("utf-16-le")
result: dict[str, list[str]] = {}
for entry in text_styles:
start_s, length_s, style = entry.split(":", 2)
start, length = int(start_s), int(length_s)
span = encoded[start * 2 : (start + length) * 2].decode("utf-16-le")
result.setdefault(span, []).append(style)
return result
# ---------------------------------------------------------------------------
# Basic cases
# ---------------------------------------------------------------------------
def test_empty():
plain, styles = _markdown_to_signal("")
assert plain == ""
assert styles == []
def test_plain_text():
plain, styles = _markdown_to_signal("hello world")
assert plain == "hello world"
assert styles == []
def test_bold_stars():
plain, styles = _markdown_to_signal("say **hello** now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_bold_underscores():
plain, styles = _markdown_to_signal("say __hello__ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["BOLD"]}
def test_italic_star():
plain, styles = _markdown_to_signal("say *hello* now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_italic_underscore():
plain, styles = _markdown_to_signal("say _hello_ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["ITALIC"]}
def test_strikethrough():
plain, styles = _markdown_to_signal("say ~~hello~~ now")
assert plain == "say hello now"
assert styles_for(plain, styles) == {"hello": ["STRIKETHROUGH"]}
# ---------------------------------------------------------------------------
# Code
# ---------------------------------------------------------------------------
def test_inline_code():
plain, styles = _markdown_to_signal("run `ls -la` here")
assert plain == "run ls -la here"
assert styles_for(plain, styles) == {"ls -la": ["MONOSPACE"]}
def test_code_block():
plain, styles = _markdown_to_signal("```\nprint('hi')\n```")
assert "print('hi')" in plain
assert styles_for(plain, styles).get("print('hi')\n") == ["MONOSPACE"] or "MONOSPACE" in str(
styles_for(plain, styles)
)
def test_code_block_with_lang():
plain, styles = _markdown_to_signal("```python\ncode\n```")
assert "code" in plain
assert any("MONOSPACE" in s for s in styles)
def test_code_block_not_processed_further():
"""Markdown inside a code block must not be styled."""
plain, styles = _markdown_to_signal("```\n**not bold**\n```")
assert "**not bold**" in plain
# Only MONOSPACE should be applied, no BOLD
for entry in styles:
assert "BOLD" not in entry
def test_inline_code_not_processed_further():
"""Markdown inside inline code must not be styled."""
plain, styles = _markdown_to_signal("use `**raw**` please")
assert "**raw**" in plain
for entry in styles:
assert "BOLD" not in entry
# ---------------------------------------------------------------------------
# Headers
# ---------------------------------------------------------------------------
def test_header_becomes_bold():
plain, styles = _markdown_to_signal("# My Title")
assert plain == "My Title"
assert styles_for(plain, styles) == {"My Title": ["BOLD"]}
def test_h2_becomes_bold():
plain, styles = _markdown_to_signal("## Sub-section")
assert plain == "Sub-section"
assert styles_for(plain, styles) == {"Sub-section": ["BOLD"]}
# ---------------------------------------------------------------------------
# Blockquotes
# ---------------------------------------------------------------------------
def test_blockquote_strips_marker():
plain, styles = _markdown_to_signal("> some quote")
assert plain == "some quote"
assert styles == []
# ---------------------------------------------------------------------------
# Lists
# ---------------------------------------------------------------------------
def test_bullet_dash():
plain, styles = _markdown_to_signal("- item one")
assert plain == "• item one"
def test_bullet_star():
plain, styles = _markdown_to_signal("* item two")
assert plain == "• item two"
def test_numbered_list():
plain, styles = _markdown_to_signal("1. first\n2. second")
assert "1. first" in plain
assert "2. second" in plain
# ---------------------------------------------------------------------------
# Links
# ---------------------------------------------------------------------------
def test_link_text_differs_from_url():
plain, styles = _markdown_to_signal("[Click here](https://example.com)")
assert plain == "Click here (https://example.com)"
assert styles == []
def test_link_text_equals_url():
plain, styles = _markdown_to_signal("[https://example.com](https://example.com)")
assert plain == "https://example.com"
assert styles == []
def test_link_text_equals_url_without_scheme():
plain, styles = _markdown_to_signal("[example.com](https://example.com)")
assert plain == "https://example.com"
# ---------------------------------------------------------------------------
# Mixed / nesting
# ---------------------------------------------------------------------------
def test_bold_and_italic_adjacent():
plain, styles = _markdown_to_signal("**bold** and *italic*")
assert plain == "bold and italic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_header_with_inline_code():
"""Header becomes BOLD; code inside becomes MONOSPACE (not double-BOLD)."""
plain, styles = _markdown_to_signal("# Use `grep`")
assert plain == "Use grep"
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Use ", []) or "BOLD" in str(styles)
assert "MONOSPACE" in sd.get("grep", [])
def test_multiline_mixed():
md = "**Title**\n\nSome *italic* text.\n\n- bullet\n- another"
plain, styles = _markdown_to_signal(md)
assert "Title" in plain
assert "italic" in plain
assert "• bullet" in plain
sd = styles_for(plain, styles)
assert "BOLD" in sd.get("Title", [])
assert "ITALIC" in sd.get("italic", [])
# ---------------------------------------------------------------------------
# Table rendering
# ---------------------------------------------------------------------------
def test_table_rendered_as_monospace():
md = "| A | B |\n| - | - |\n| 1 | 2 |"
plain, styles = _markdown_to_signal(md)
assert "A" in plain and "B" in plain
assert any("MONOSPACE" in s for s in styles)
# ---------------------------------------------------------------------------
# Style range format
# ---------------------------------------------------------------------------
def test_style_range_format():
"""Each style entry must be 'start:length:STYLE'."""
_, styles = _markdown_to_signal("**bold** text")
for entry in styles:
parts = entry.split(":")
assert len(parts) == 3
assert parts[0].isdigit()
assert parts[1].isdigit()
assert parts[2] in {"BOLD", "ITALIC", "STRIKETHROUGH", "MONOSPACE", "SPOILER"}
def test_style_ranges_are_within_bounds():
text = "hello **world** end"
plain, styles = _markdown_to_signal(text)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= len(plain)
# ---------------------------------------------------------------------------
# Non-BMP / UTF-16 offsets
#
# Signal's BodyRange (and signal-cli's textStyle) interprets start/length in
# UTF-16 code units. Python's len() counts code points, so characters outside
# the BMP (emojis, supplementary CJK) shift offsets by +1 per occurrence.
# ---------------------------------------------------------------------------
def assert_within_utf16_bounds(plain: str, styles: list[str]) -> None:
limit = _utf16_len(plain)
for entry in styles:
start_s, length_s, _ = entry.split(":", 2)
start, length = int(start_s), int(length_s)
assert start >= 0
assert start + length <= limit, f"range {entry} exceeds utf-16 length {limit} of {plain!r}"
def test_bold_with_emoji_inside():
plain, styles = _markdown_to_signal("**hi 🎉 bye**")
assert plain == "hi 🎉 bye"
assert utf16_styles_for(plain, styles) == {"hi 🎉 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_italic_with_trailing_emoji():
plain, styles = _markdown_to_signal("*bye 🎉*")
assert plain == "bye 🎉"
assert utf16_styles_for(plain, styles) == {"bye 🎉": ["ITALIC"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_emoji_prefix():
plain, styles = _markdown_to_signal("🎉 **bold**")
assert plain == "🎉 bold"
assert utf16_styles_for(plain, styles) == {"bold": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_bold_after_and_inside_emoji():
plain, styles = _markdown_to_signal("🎉 **a 🎊 b**")
assert plain == "🎉 a 🎊 b"
assert utf16_styles_for(plain, styles) == {"a 🎊 b": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_supplementary_cjk_in_bold():
"""Non-BMP CJK (U+20BB7) proves the bug is UTF-16, not emoji-specific."""
plain, styles = _markdown_to_signal("**𠮷野家**")
assert plain == "𠮷野家"
assert utf16_styles_for(plain, styles) == {"𠮷野家": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_zwj_emoji_in_bold():
"""ZWJ family sequence = multiple surrogate pairs + BMP ZWJs."""
plain, styles = _markdown_to_signal("**hi 👨‍👩‍👧 bye**")
assert plain == "hi 👨‍👩‍👧 bye"
assert utf16_styles_for(plain, styles) == {"hi 👨‍👩‍👧 bye": ["BOLD"]}
assert_within_utf16_bounds(plain, styles)
def test_ascii_offsets_unchanged():
"""ASCII-only path must produce the same offsets as before the UTF-16 fix."""
plain, styles = _markdown_to_signal("**bold** plain *it*")
assert plain == "bold plain it"
assert sorted(styles) == sorted(["0:4:BOLD", "11:2:ITALIC"])
def test_reported_daily_brief_pattern():
"""Regression for the reported bug: a single non-BMP emoji shifts every
subsequent styled span left by 1 UTF-16 unit, lopping off the last letter.
"""
md = (
"**Weather**\n"
"- Conditions: 🌩️ Thunderstorms\n\n"
"**News**\n"
"*World*\n"
"*Local*\n\n"
"**Quote of the Day**"
)
plain, styles = _markdown_to_signal(md)
sd = utf16_styles_for(plain, styles)
assert sd.get("Weather") == ["BOLD"]
assert sd.get("News") == ["BOLD"]
assert sd.get("World") == ["ITALIC"]
assert sd.get("Local") == ["ITALIC"]
assert sd.get("Quote of the Day") == ["BOLD"]
assert_within_utf16_bounds(plain, styles)
# ---------------------------------------------------------------------------
# Chunk redistribution
#
# split_message can break a long Signal payload into multiple chunks. The
# style ranges from _markdown_to_signal are anchored to the full text, so
# they must be redistributed per-chunk with rebased offsets — otherwise
# styles for chunks 1..N are silently lost.
# ---------------------------------------------------------------------------
def _resolve_chunk_styles(text: str, max_len: int) -> tuple[list[str], list[list[str]]]:
"""Helper: full markdown → signal pipeline, including chunking."""
plain, styles = _markdown_to_signal(text)
chunks = split_message(plain, max_len) if plain else [""]
return chunks, _partition_styles(plain, chunks, styles)
def test_partition_styles_single_chunk_passthrough():
plain, styles = _markdown_to_signal("**bold** plain *it*")
parts = _partition_styles(plain, [plain], styles)
assert parts == [styles]
def test_partition_styles_no_styles():
plain = "hello world"
assert _partition_styles(plain, [plain], []) == [[]]
assert _partition_styles(plain, ["hello", "world"], []) == [[], []]
def test_partition_styles_drops_styles_outside_chunks():
"""Whitespace trimmed by split_message must not carry a style range."""
plain = "a b"
# Fake a style spanning the trimmed whitespace only.
chunks = ["a", "b"]
parts = _partition_styles(plain, chunks, ["1:3:BOLD"])
assert parts == [[], []]
def test_partition_styles_long_message_preserves_chunk_one_styles():
"""A bold span deep in the message must follow the message into chunk 1."""
# Two ~30-char paragraphs separated by a blank line, then **tail**.
line_a = "alpha " * 5 # 30 chars, ends with space
line_b = "beta " * 5
md = f"{line_a.strip()}\n\n{line_b.strip()}\n\n**tail**"
plain, styles = _markdown_to_signal(md)
# Force a split between the paragraphs.
max_len = len(line_a.strip()) + 2 # fits paragraph A + the "\n\n"
chunks = split_message(plain, max_len)
assert len(chunks) >= 2, "test setup must produce a split"
parts = _partition_styles(plain, chunks, styles)
# The bold "tail" should land in the last chunk, with chunk-relative offset.
final_chunk = chunks[-1]
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_chunk_zero_styles_unchanged():
"""Styles entirely in chunk 0 keep their original offsets."""
md = "**head** middle and **tail**"
plain, styles = _markdown_to_signal(md)
# Split so chunk 0 contains "head" and part of the rest, chunk 1 contains "tail".
chunks = split_message(plain, 12)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
# "head" lives in chunk 0; assert its offset is unchanged (chunk 0 starts at 0).
head_entries = [s for s in parts[0] if "BOLD" in s]
assert any(s.startswith("0:4:") for s in head_entries)
def test_partition_styles_with_non_bmp_chunk_offset():
"""Chunk-start offsets must be expressed in UTF-16 code units."""
# Emoji in chunk 0, bold in chunk 1.
md = "🎉 alpha beta gamma\n\n**tail**"
plain, styles = _markdown_to_signal(md)
chunks = split_message(plain, 18)
assert len(chunks) >= 2
parts = _partition_styles(plain, chunks, styles)
final_styles = parts[-1]
assert any("BOLD" in s for s in final_styles)
final_chunk = chunks[-1]
for entry in final_styles:
s, ln, _ = entry.split(":", 2)
start, length = int(s), int(ln)
slice_ = final_chunk.encode("utf-16-le")[start * 2 : (start + length) * 2].decode(
"utf-16-le"
)
assert slice_ == "tail"
def test_partition_styles_range_spanning_chunks_is_split():
"""A style range that straddles a chunk boundary gets sliced into both chunks."""
# Construct manually: plain = "abc def", style covers "abc def" (whole thing).
plain = "abc def"
chunks = split_message(plain, 4) # "abc" / "def"
assert chunks == ["abc", "def"]
parts = _partition_styles(plain, chunks, ["0:7:BOLD"])
# Chunk 0 holds 0:3:BOLD, chunk 1 holds 0:3:BOLD (length=3 each, "def" only
# since the space was trimmed by lstrip).
assert parts[0] == ["0:3:BOLD"]
assert parts[1] == ["0:3:BOLD"]
# ---------------------------------------------------------------------------
# Adjacency, nesting, and malformed input
# ---------------------------------------------------------------------------
def test_bold_italic_combo_outer_bold_inner_italic():
"""`**_combo_**` carries both BOLD and ITALIC over the same span."""
plain, styles = _markdown_to_signal("**_combo_**")
assert plain == "combo"
sd = styles_for(plain, styles)
assert set(sd.get("combo", [])) == {"BOLD", "ITALIC"}
def test_bold_and_italic_adjacent_no_separator():
"""`**bold***italic*` produces BOLD on `bold` and ITALIC on `italic`."""
plain, styles = _markdown_to_signal("**bold***italic*")
assert plain == "bolditalic"
sd = styles_for(plain, styles)
assert sd.get("bold") == ["BOLD"]
assert sd.get("italic") == ["ITALIC"]
def test_unclosed_bold_falls_through_as_plain():
"""An unmatched `**` opener round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("**bold")
assert plain == "**bold"
assert styles == []
def test_unclosed_inline_code_falls_through_as_plain():
"""An unmatched backtick round-trips as literal text with no style."""
plain, styles = _markdown_to_signal("use `grep")
assert plain == "use `grep"
assert styles == []
def test_inline_code_inside_blockquote():
"""Blockquote prefix is stripped; inline code becomes MONOSPACE."""
plain, styles = _markdown_to_signal("> use `grep`")
assert plain == "use grep"
sd = styles_for(plain, styles)
assert sd.get("grep") == ["MONOSPACE"]
def test_header_with_inner_bold_produces_contiguous_bold_ranges():
"""`# **wrap** me` — header forces BOLD over the whole line; the inner `**`
splits the run, yielding two contiguous BOLD ranges that together cover
"wrap me". This is intentional Signal renders adjacent same-style ranges
as a single visual span.
"""
plain, styles = _markdown_to_signal("# **wrap** me")
assert plain == "wrap me"
# Both ranges are BOLD; collectively they cover the whole "wrap me".
bold_ranges = [s for s in styles if s.endswith(":BOLD")]
assert len(bold_ranges) == 2
covered = set()
for entry in bold_ranges:
start, length, _ = entry.split(":", 2)
for i in range(int(start), int(start) + int(length)):
covered.add(i)
assert covered == set(range(len(plain)))
-94
View File
@@ -480,99 +480,6 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
assert second["stream_id"] == "sid"
@pytest.mark.asyncio
async def test_send_delta_stream_end_rewrites_local_markdown_image(monkeypatch, tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
media = tmp_path / "media"
def fake_media_dir(channel: str | None = None):
path = media / channel if channel else media
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta("chat-1", "![Diagram](", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "diagram.png)", {"_stream_delta": True, "_stream_id": "sid"})
await channel.send_delta("chat-1", "", {"_stream_end": True, "_stream_id": "sid"})
assert mock_ws.send.await_count == 3
final = json.loads(mock_ws.send.call_args_list[2][0][0])
assert final["event"] == "stream_end"
assert final["text"].startswith("![Diagram](/api/media/")
@pytest.mark.asyncio
async def test_send_delta_stream_end_rewrites_inline_final_text(monkeypatch, tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
media = tmp_path / "media"
def fake_media_dir(channel: str | None = None):
path = media / channel if channel else media
path.mkdir(parents=True, exist_ok=True)
return path
monkeypatch.setattr("nanobot.channels.websocket.get_media_dir", fake_media_dir)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
channel._webui_chats.add("chat-1")
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
final = json.loads(mock_ws.send.await_args.args[0])
assert final["event"] == "stream_end"
assert final["text"].startswith("![Diagram](/api/media/")
@pytest.mark.asyncio
async def test_send_delta_stream_end_leaves_non_webui_payload_unchanged(tmp_path) -> None:
bus = MagicMock()
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"], "streaming": True},
bus,
workspace_path=workspace,
)
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
await channel.send_delta(
"chat-1",
"![Diagram](diagram.png)",
{"_stream_delta": True, "_stream_end": True, "_stream_id": "sid"},
)
mock_ws.send.assert_awaited_once()
final = json.loads(mock_ws.send.await_args.args[0])
assert final == {"event": "stream_end", "chat_id": "chat-1", "stream_id": "sid"}
@pytest.mark.asyncio
async def test_send_reasoning_delta_emits_streaming_frame() -> None:
bus = MagicMock()
@@ -1148,7 +1055,6 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist(
}
assert image_providers["openrouter"]["label"] == "OpenRouter"
assert image_providers["openrouter"]["configured"] is False
assert image_providers["openai_codex"]["configured"] is True
assert image_providers["gemini"]["label"] == "Gemini"
assert body["runtime"]["config_path"] == str(config_path)
workspace_path = body["runtime"]["workspace_path"].replace("\\", "/")
@@ -105,43 +105,6 @@ async def test_message_without_media_backward_compatible() -> None:
assert call.kwargs["media"] is None
@pytest.mark.asyncio
async def test_message_forwards_normalized_cli_app_attachments() -> None:
channel = _make_channel()
mock_conn = AsyncMock()
envelope = {
"type": "message",
"chat_id": "abc123",
"content": "please use @drawio",
"webui": True,
"cli_apps": [
{
"name": "DrawIO",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
},
{"name": "bad name", "entry_point": "nope"},
],
}
await channel._dispatch_envelope(mock_conn, "client-1", envelope)
channel._handle_message.assert_awaited_once()
metadata = channel._handle_message.call_args.kwargs["metadata"]
assert metadata["webui"] is True
assert metadata["cli_apps"] == [{
"name": "drawio",
"display_name": "Draw.io",
"category": "diagram",
"entry_point": "cli-anything-drawio",
"logo_url": "https://example.invalid/drawio.svg",
"brand_color": "#F08705",
}]
@pytest.mark.asyncio
async def test_message_with_single_image_forwards_saved_path(tmp_path) -> None:
channel = _make_channel()
@@ -140,75 +140,6 @@ async def test_sessions_routes_require_bearer_token(
await server_task
@pytest.mark.asyncio
async def test_cli_apps_routes_require_token_and_return_payload(
bus: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_payload",
lambda: {
"apps": [
{
"name": "gimp",
"display_name": "GIMP",
"category": "image",
"description": "Image editing",
"requires": "Python",
"source": "harness",
"entry_point": "cli-anything-gimp",
"install_supported": True,
"installed": False,
"available": False,
"status": "not_installed",
"logo_url": None,
"brand_color": None,
"skill_installed": False,
}
],
"installed_count": 0,
"catalog_updated_at": "2026-04-18",
},
)
monkeypatch.setattr(
"nanobot.channels.websocket.cli_apps_action",
lambda action, query: {
"apps": [],
"installed_count": 1,
"catalog_updated_at": "2026-04-18",
"last_action": {"ok": True, "message": f"{action}:{query['name'][0]}"},
},
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
assert deny.status_code == 401
boot = await _http_get("http://127.0.0.1:29912/webui/bootstrap")
token = boot.json()["token"]
auth = {"Authorization": f"Bearer {token}"}
catalog = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps",
headers=auth,
)
assert catalog.status_code == 200
assert catalog.json()["apps"][0]["name"] == "gimp"
installed = await _http_get(
"http://127.0.0.1:29912/api/settings/cli-apps/install?name=gimp",
headers=auth,
)
assert installed.status_code == 200
assert installed.json()["last_action"]["message"] == "install:gimp"
finally:
await channel.stop()
await server_task
@pytest.mark.asyncio
async def test_sessions_list_only_returns_websocket_sessions_by_default(
bus: MagicMock, tmp_path: Path
@@ -44,7 +44,6 @@ def _ch(
bus: Any,
*,
session_manager: SessionManager | None = None,
workspace_path: Path | None = None,
port: int,
) -> WebSocketChannel:
return WebSocketChannel(
@@ -58,7 +57,6 @@ def _ch(
},
bus,
session_manager=session_manager,
workspace_path=workspace_path,
)
@@ -69,15 +67,6 @@ def bus() -> MagicMock:
return b
def _fake_media_dir(root: Path):
def inner(channel: str | None = None) -> Path:
path = root / channel if channel else root
path.mkdir(parents=True, exist_ok=True)
return path
return inner
async def _http_get(
url: str, headers: dict[str, str] | None = None
) -> httpx.Response:
@@ -134,45 +123,6 @@ def test_sign_media_path_round_trips_via_hmac(
assert _b64url_decode(payload).decode() == "a.png"
def test_local_markdown_image_is_staged_and_rewritten(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
(workspace / "demo_arch.png").write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
rewritten = channel._rewrite_local_markdown_images(
"The result:\n![Cloud Architecture Diagram](demo_arch.png)"
)
assert "![Cloud Architecture Diagram](/api/media/" in rewritten
staged = list((media / "websocket").iterdir())
assert len(staged) == 1
assert staged[0].read_bytes() == _PNG_BYTES
def test_local_markdown_image_rejects_workspace_escape(
bus: MagicMock,
tmp_path: Path,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
outside = tmp_path / "outside.png"
outside.write_bytes(_PNG_BYTES)
media = tmp_path / "media"
channel = _ch(bus, workspace_path=workspace, port=0)
text = "![nope](../outside.png)"
with patch("nanobot.channels.websocket.get_media_dir", side_effect=_fake_media_dir(media)):
assert channel._rewrite_local_markdown_images(text) == text
assert not (media / "websocket").exists()
# ---------------------------------------------------------------------------
# /api/media/<sig>/<payload>: the serving handler
# ---------------------------------------------------------------------------
-526
View File
@@ -1,7 +1,6 @@
import asyncio
import json
import tempfile
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -375,7 +374,6 @@ async def test_send_uses_typing_start_and_cancel_when_ticket_available() -> None
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-typing"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
channel._api_post = AsyncMock(
side_effect=[
@@ -404,7 +402,6 @@ async def test_send_still_sends_text_when_typing_ticket_missing() -> None:
channel._client = object()
channel._token = "token"
channel._context_tokens["wx-user"] = "ctx-no-ticket"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
channel._api_post = AsyncMock(return_value={"ret": 1, "errmsg": "no config"})
@@ -1257,526 +1254,3 @@ async def test_send_text_succeeds_on_zero_errcode() -> None:
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_text_raises_on_nonzero_ret_even_when_errcode_zero() -> None:
"""_send_text must raise when the API returns ret != 0, even if errcode is 0.
The iLink API signals failure through either field. Checking only errcode
caused silent message drops (responses generated but never delivered).
"""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel._api_post = AsyncMock(
return_value={"ret": -100, "errcode": 0, "errmsg": "internal error"}
)
with pytest.raises(RuntimeError, match="WeChat send text error.*ret=-100.*errcode=0"):
await channel._send_text("wx-user", "hello", "ctx-ok")
channel._api_post.assert_awaited_once()
# ---------------------------------------------------------------------------
# Tests for _poll_once not silently dropping messages on processing errors
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_poll_once_logs_exception_on_process_message_failure(monkeypatch) -> None:
"""When _process_message raises, _poll_once must log the error and continue
processing remaining messages instead of silently swallowing the exception."""
channel, _bus = _make_channel()
channel._client = SimpleNamespace(timeout=None)
channel._token = "token"
channel._get_updates_buf = "old-buf"
calls = []
logged_messages: list[str] = []
async def _failing_process(msg: dict) -> None:
calls.append(msg.get("message_id"))
if msg.get("message_id") == "msg-1":
raise RuntimeError("processing failed")
channel._process_message = _failing_process # type: ignore[method-assign]
monkeypatch.setattr(
channel.logger,
"exception",
lambda message, *args, **kwargs: logged_messages.append(str(message)),
)
channel._api_post = AsyncMock( # type: ignore[method-assign]
return_value={
"ret": 0,
"errcode": 0,
"get_updates_buf": "new-buf",
"msgs": [
{"message_id": "msg-1", "message_type": 1},
{"message_id": "msg-2", "message_type": 1},
],
}
)
await channel._poll_once()
# Both messages should have been attempted
assert calls == ["msg-1", "msg-2"]
# Buffer should still advance (already updated before processing)
assert channel._get_updates_buf == "new-buf"
# Error should be logged
assert any("Failed to process WeChat message" in m for m in logged_messages)
@pytest.mark.asyncio
async def test_poll_loop_logs_exception_and_continues_on_poll_failure(monkeypatch) -> None:
"""When _poll_once raises a non-timeout exception, the start() loop must log
the error and continue polling instead of exiting silently."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.config.token = "token" # skip QR login in start()
channel._running = True
call_count = 0
logged_messages: list[str] = []
async def _failing_poll() -> None:
nonlocal call_count
call_count += 1
if call_count == 1:
raise RuntimeError("poll exploded")
channel._running = False # Stop after second call
channel._poll_once = _failing_poll # type: ignore[method-assign]
monkeypatch.setattr(
channel.logger,
"exception",
lambda message, *args, **kwargs: logged_messages.append(str(message)),
)
# Use a tiny retry delay so the test finishes quickly
original_retry = weixin_mod.RETRY_DELAY_S
weixin_mod.RETRY_DELAY_S = 0.01
try:
await channel.start()
finally:
weixin_mod.RETRY_DELAY_S = original_retry
assert call_count == 2
assert any("WeChat poll loop error" in m for m in logged_messages)
# ---------------------------------------------------------------------------
# Tool-hint buffering
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_buffer_single_tool_hint_not_sent_immediately() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Using tool",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["Using tool"]
@pytest.mark.asyncio
async def test_buffer_multiple_tool_hints_flushed_on_final_answer() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
for hint in ["tool1", "tool2"]:
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": hint,
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 2
channel._send_text.assert_any_await("wx-user", "tool1\n\ntool2", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_thought_progress_flushes_tool_hints() -> None:
"""Thoughts are visible progress messages and must act as separators,
flushing buffered tool hints before they are sent."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send a thought — progress but not a tool_hint.
# It must act as a separator and flush the buffered hint.
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Let me think...",
"media": [],
"metadata": {"_progress": True},
},
)()
)
# The buffered hint was flushed before the thought was sent.
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Let me think...", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
# Final answer arrives with nothing left to flush.
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 3
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
@pytest.mark.asyncio
async def test_reasoning_delta_does_not_flush_tool_hints() -> None:
"""Reasoning deltas are invisible in WeChat and must NOT flush buffered
tool hints otherwise hints separated only by hidden reasoning would
fail to coalesce."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send a reasoning delta — invisible in WeChat, must NOT flush
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Thinking step 1...",
"media": [],
"metadata": {"_progress": True, "_reasoning_delta": True},
},
)()
)
# Reasoning is invisible; hint stays buffered, _send_text not called
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
# Final answer flushes the buffered hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_empty_progress_message_does_not_flush_tool_hints() -> None:
"""Empty progress messages (e.g. after_iteration tool_events) have no
visible content and must NOT act as separators."""
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
# Buffer a tool hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "search 'foo'",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
# Send an empty progress message (no content, no media)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "",
"media": [],
"metadata": {"_progress": True, "_tool_events": [{"phase": "end"}]},
},
)()
)
# Nothing should have been sent yet
channel._send_text.assert_not_awaited()
assert channel._pending_tool_hints["wx-user"] == ["search 'foo'"]
# Final answer flushes the buffered hint
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
channel._send_text.assert_any_await("wx-user", "search 'foo'", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_buffer_flush_refreshes_context_token() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-old"
channel._context_token_at["wx-user"] = time.time()
channel._refresh_context_token_if_stale = AsyncMock(return_value="ctx-refreshed")
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._refresh_context_token_if_stale.await_count == 2
channel._refresh_context_token_if_stale.assert_any_await("wx-user", "ctx-old")
channel._send_text.assert_any_await("wx-user", "hint", "ctx-refreshed")
@pytest.mark.asyncio
async def test_buffer_flush_failure_does_not_block_final_answer() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock(side_effect=[RuntimeError("boom"), None])
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "Done",
"media": [],
"metadata": {},
},
)()
)
assert channel._send_text.await_count == 2
channel._send_text.assert_any_await("wx-user", "hint", "ctx-1")
channel._send_text.assert_any_await("wx-user", "Done", "ctx-1")
@pytest.mark.asyncio
async def test_buffer_flushed_on_stream_end() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = True
channel._context_tokens["wx-user"] = "ctx-1"
channel._context_token_at["wx-user"] = time.time()
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
await channel.send_delta("wx-user", "", {"_stream_end": True})
channel._send_text.assert_awaited_once_with("wx-user", "hint", "ctx-1")
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_stop_clears_buffer() -> None:
channel, _bus = _make_channel()
channel._pending_tool_hints["wx-user"] = ["hint1", "hint2"]
await channel.stop()
assert "wx-user" not in channel._pending_tool_hints
@pytest.mark.asyncio
async def test_send_tool_hints_false_drops_tool_hints() -> None:
channel, _bus = _make_channel()
channel._client = object()
channel._token = "token"
channel.send_tool_hints = False
channel._send_text = AsyncMock()
await channel.send(
type(
"Msg",
(),
{
"chat_id": "wx-user",
"content": "hint",
"media": [],
"metadata": {"_progress": True, "_tool_hint": True},
},
)()
)
channel._send_text.assert_not_awaited()
assert "wx-user" not in channel._pending_tool_hints
+199 -1
View File
@@ -11,7 +11,7 @@ from typer.testing import CliRunner
from nanobot.bus.events import OutboundMessage
from nanobot.cli.commands import app
from nanobot.providers.factory import make_provider
from nanobot.config.schema import Config
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.cron.types import CronJob, CronPayload
from nanobot.providers.factory import ProviderSnapshot
from nanobot.providers.openai_codex_provider import _strip_model_prefix
@@ -226,6 +226,16 @@ def test_config_dump_excludes_oauth_provider_blocks():
assert "openaiCodex" not in providers
assert "githubCopilot" not in providers
assert "xaiOauth" not in providers
def test_config_dump_includes_xai_oauth_when_hosted_search_is_disabled():
config = Config()
config.providers.xai_oauth.x_search.enable = False
providers = config.model_dump(by_alias=True)["providers"]
assert providers["xaiOauth"]["xSearch"]["enable"] is False
def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch):
@@ -280,6 +290,175 @@ def test_provider_logout_github_copilot_succeeds_when_no_local_oauth_file(monkey
assert "No local OAuth credentials found for GitHub Copilot" in result.stdout
def test_provider_logout_xai_oauth_removes_local_oauth_files(tmp_path, monkeypatch):
token_path = tmp_path / "auth" / "xai-oauth.json"
lock_path = token_path.with_suffix(".lock")
token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text("{}", encoding="utf-8")
lock_path.write_text("", encoding="utf-8")
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
monkeypatch.setattr("nanobot.providers.xai_oauth_provider._keyring_delete", lambda: None)
result = runner.invoke(app, ["provider", "logout", "xai-oauth"])
assert result.exit_code == 0
assert not token_path.exists()
assert not lock_path.exists()
assert "Logged out from xAI Grok OAuth" in result.stdout
def test_provider_logout_xai_oauth_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path):
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
monkeypatch.setattr("nanobot.providers.xai_oauth_provider._keyring_delete", lambda: None)
result = runner.invoke(app, ["provider", "logout", "xai-oauth"])
assert result.exit_code == 0
assert "No local OAuth credentials found for xAI Grok OAuth" in result.stdout
def test_provider_login_xai_oauth_forwards_manual_options(monkeypatch):
from nanobot.providers.xai_oauth_provider import XaiOAuthCredential
captured: dict[str, object] = {}
def fake_login_xai_oauth_interactive(**kwargs):
captured.update(kwargs)
return XaiOAuthCredential(access_token="access", account_id="acct", storage="keyring")
monkeypatch.setattr(
"nanobot.providers.xai_oauth_provider.login_xai_oauth_interactive",
fake_login_xai_oauth_interactive,
)
result = runner.invoke(app, ["provider", "login", "xai-oauth", "--no-browser", "--manual-paste"])
assert result.exit_code == 0
assert captured["open_browser"] is False
assert captured["manual_paste"] is True
assert "Authenticated with xAI Grok OAuth" in result.stdout
assert "nanobot config set agents.defaults.provider xai-oauth" in result.stdout
def test_config_set_updates_default_model_selection(tmp_path):
config_path = tmp_path / "config.json"
result = runner.invoke(app, [
"config",
"set",
"--config",
str(config_path),
"agents.defaults.model_preset",
"null",
])
assert result.exit_code == 0
result = runner.invoke(app, [
"config",
"set",
"--config",
str(config_path),
"agents.defaults.provider",
"xai-oauth",
])
assert result.exit_code == 0
result = runner.invoke(app, [
"config",
"set",
"--config",
str(config_path),
"agents.defaults.model",
"xai-oauth/grok-4.3",
])
assert result.exit_code == 0
data = json.loads(config_path.read_text(encoding="utf-8"))
config = Config.model_validate(data)
assert config.agents.defaults.model_preset is None
assert config.agents.defaults.provider == "xai-oauth"
assert config.agents.defaults.model == "xai-oauth/grok-4.3"
def test_config_set_warns_when_model_preset_would_override_selection(tmp_path):
config = Config()
config.agents.defaults.model_preset = "fast"
config.model_presets["fast"] = ModelPresetConfig(
provider="openrouter",
model="openrouter/openai/gpt-4o-mini",
)
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps(config.model_dump(mode="json", by_alias=True)), encoding="utf-8")
result = runner.invoke(app, [
"config",
"set",
"--config",
str(config_path),
"agents.defaults.provider",
"xai-oauth",
])
assert result.exit_code == 0
assert "model_preset is set and may override this" in result.stdout
def test_config_set_disables_xai_oauth_hosted_search(tmp_path):
config_path = tmp_path / "config.json"
result = runner.invoke(app, [
"config",
"set",
"--config",
str(config_path),
"providers.xai_oauth.x_search.enable",
"false",
])
assert result.exit_code == 0
data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["providers"]["xaiOauth"]["xSearch"]["enable"] is False
assert Config.model_validate(data).providers.xai_oauth.x_search.enable is False
def test_config_set_rejects_unknown_path(tmp_path):
result = runner.invoke(app, [
"config",
"set",
"--config",
str(tmp_path / "config.json"),
"agents.defaults.not_a_field",
"value",
])
assert result.exit_code == 1
assert "Could not set config value" in result.stdout
def test_provider_login_xai_oauth_does_not_update_config(monkeypatch, tmp_path):
from nanobot.providers.xai_oauth_provider import XaiOAuthCredential
config = Config()
config.agents.defaults.provider = "auto"
config.agents.defaults.model = "anthropic/claude-opus-4-5"
config_path = tmp_path / "config.json"
monkeypatch.setattr(
"nanobot.providers.xai_oauth_provider.login_xai_oauth_interactive",
lambda **_kwargs: XaiOAuthCredential(access_token="access", account_id="acct", storage="keyring"),
)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config)
save_config = MagicMock()
monkeypatch.setattr("nanobot.config.loader.save_config", save_config)
result = runner.invoke(app, ["provider", "login", "xai-oauth"])
assert result.exit_code == 0
save_config.assert_not_called()
assert "nanobot config set agents.defaults.model xai-oauth/grok-4.3" in result.stdout
def test_provider_logout_rejects_unknown_provider():
result = runner.invoke(app, ["provider", "logout", "not-a-real-provider"])
@@ -398,6 +577,8 @@ def test_find_by_name_accepts_camel_case_and_hyphen_aliases():
assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan"
assert find_by_name("github-copilot") is not None
assert find_by_name("github-copilot").name == "github_copilot"
assert find_by_name("xai-oauth") is not None
assert find_by_name("xai-oauth").name == "xai_oauth"
assert find_by_name("longcat") is not None
assert find_by_name("longcat").name == "longcat"
assert find_by_name("atomic-chat") is not None
@@ -540,6 +721,23 @@ def test_make_provider_uses_github_copilot_backend():
assert provider.__class__.__name__ == "GitHubCopilotProvider"
def test_make_provider_uses_xai_oauth_backend():
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "xai-oauth",
"model": "xai-oauth/grok-4.3",
}
}
}
)
provider = make_provider(config)
assert provider.__class__.__name__ == "XaiOAuthProvider"
def test_github_copilot_provider_strips_prefixed_model_name():
from nanobot.providers.github_copilot_provider import GitHubCopilotProvider
-408
View File
@@ -1,408 +0,0 @@
from __future__ import annotations
import json
import subprocess
import sys
import time
from pathlib import Path
import pytest
from nanobot.cli_apps.service import CliAppError, CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def _manager(tmp_path: Path) -> CliAppManager:
workspace = tmp_path / "workspace"
workspace.mkdir()
return CliAppManager(
workspace=workspace,
data_dir=tmp_path / "data",
runtime=CliAppsRuntimeConfig(catalog_ttl_seconds=3600, install_timeout=5, run_timeout=5),
)
def _seed_catalog(manager: CliAppManager) -> None:
harness = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"requires": "Python 3.10+",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
"skill_md": "skills/cli-anything-gimp/SKILL.md",
}
],
}
public = {
"meta": {"updated": "2026-04-18"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"description": "Public duplicate entry",
},
{
"name": "jimeng",
"display_name": "Jimeng",
"version": "latest",
"description": "Script install",
"category": "ai",
"install_strategy": "script",
"install_cmd": "curl -fsSL https://example.invalid/install.sh | bash",
"entry_point": "dreamina",
},
{
"name": "feishu",
"display_name": "Feishu/Lark CLI",
"version": "latest",
"description": "Official Lark CLI",
"category": "communication",
"package_manager": "npm",
"npm_package": "@larksuite/cli",
"install_cmd": "npm install -g @larksuite/cli",
"entry_point": "lark-cli",
},
{
"name": "dify-workflow",
"display_name": "Dify Workflow",
"version": "latest",
"description": "Run Dify workflows",
"category": "ai",
"install_cmd": "pip install cli-anything-dify-workflow",
"entry_point": "cli-anything-dify-workflow",
},
{
"name": "shopify",
"display_name": "Shopify CLI",
"version": "latest",
"description": "Shopify",
"category": "web",
"package_manager": "npm",
"npm_package": "@shopify/cli",
"install_cmd": "npm install -g @shopify/cli",
"entry_point": "shopify",
},
{
"name": "clibrowser",
"display_name": "clibrowser",
"version": "latest",
"description": "Cargo install",
"category": "web",
"install_cmd": "cargo install --git https://example.invalid/clibrowser.git",
"entry_point": "clibrowser",
},
{
"name": "suno",
"display_name": "Suno CLI",
"version": "latest",
"description": "python3 pip install",
"category": "music",
"package_manager": "pip",
"install_strategy": "command",
"install_cmd": "python3 -m pip install git+https://example.invalid/suno-cli.git",
"uninstall_cmd": "python3 -m pip uninstall -y suno-cli",
"entry_point": "suno",
},
],
}
_write_cache(manager._cache_path("harness"), harness)
_write_cache(manager._cache_path("public"), public)
def test_payload_merges_catalog_and_marks_unsupported_installs(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
payload = manager.payload()
assert payload["catalog_updated_at"] == "2026-04-18"
apps = {app["name"]: app for app in payload["apps"]}
assert set(apps) == {
"clibrowser",
"dify-workflow",
"feishu",
"gimp",
"jimeng",
"shopify",
"suno",
}
assert apps["gimp"]["install_supported"] is True
assert apps["gimp"]["source"] == "harness+public"
assert apps["gimp"]["description"] == "Public duplicate entry"
assert apps["clibrowser"]["install_supported"] is False
assert apps["jimeng"]["install_supported"] is False
assert apps["suno"]["install_supported"] is True
assert apps["gimp"]["logo_url"]
assert apps["dify-workflow"]["logo_url"] == "https://cdn.simpleicons.org/dify/155EEF"
assert apps["feishu"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=larksuite.com&sz=64"
)
assert apps["jimeng"]["logo_url"] == "https://cdn.simpleicons.org/bytedance/3C8CFF"
assert apps["clibrowser"]["logo_url"] == (
"https://www.google.com/s2/favicons?domain=github.com/allthingssecurity/clibrowser&sz=64"
)
def test_install_dispatches_safe_pip_and_installs_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
monkeypatch.setattr(
manager,
"_fetch_skill_content",
lambda app: "---\nname: cli-anything-gimp\ndescription: GIMP\n---\n# GIMP\n",
)
payload = manager.install("gimp")
assert calls == [[sys.executable, "-m", "pip", "install", "cli-anything-gimp"]]
assert payload["last_action"]["ok"] is True
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert installed["gimp"]["entry_point"] == "cli-anything-gimp"
skill = manager.workspace / "skills" / "cli-app-gimp" / "SKILL.md"
assert skill.is_file()
assert 'run_cli_app` tool with `name="gimp"' in skill.read_text(encoding="utf-8")
def test_installed_state_writes_atomically_without_temp_leftovers(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
manager._save_installed({"zoom": {"entry_point": "cli-anything-zoom"}})
installed = json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert set(installed) == {"zoom"}
assert not list(manager.installed_path.parent.glob(".installed.json.*.tmp"))
def test_fetch_skill_content_rejects_untrusted_urls(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
def fail_get(*args, **kwargs):
raise AssertionError("untrusted skill URL should not be fetched")
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fail_get)
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "https://example.com/SKILL.md",
}) is None
assert manager._fetch_skill_content({
"name": "evil",
"skill_md": "skills/../evil/SKILL.md",
}) is None
def test_fetch_skill_content_allows_cli_anything_raw_skill_url(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
seen: list[str] = []
class Response:
text = "---\nname: cli-app-test\ndescription: Test\n---\n# Test\n"
@staticmethod
def raise_for_status() -> None:
return None
def fake_get(url: str, **kwargs):
seen.append(url)
return Response()
monkeypatch.setattr("nanobot.cli_apps.service.httpx.get", fake_get)
content = manager._fetch_skill_content({
"name": "gimp",
"skill_md": "https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md",
})
assert content and "# Test" in content
assert seen == [
"https://raw.githubusercontent.com/HKUDS/CLI-Anything/main/skills/cli-anything-gimp/SKILL.md"
]
def test_uninstall_removes_installed_state_and_generated_skill(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-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(
manager,
"_run_argv",
lambda argv, *, timeout: subprocess.CompletedProcess(argv, 0, stdout="ok", stderr=""),
)
payload = manager.uninstall("gimp")
assert payload["last_action"]["ok"] is True
assert "gimp" not in json.loads(manager.installed_path.read_text(encoding="utf-8"))["apps"]
assert not skill_dir.exists()
def test_uninstall_uses_safe_python_m_pip_uninstall_command(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"suno": {"entry_point": "suno"}})
calls: list[list[str]] = []
def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
calls.append(argv)
return subprocess.CompletedProcess(argv, 0, stdout="ok", stderr="")
monkeypatch.setattr(manager, "_run_argv", fake_run)
payload = manager.uninstall("suno")
assert calls == [[sys.executable, "-m", "pip", "uninstall", "-y", "suno-cli"]]
assert payload["last_action"]["ok"] is True
def test_mentioned_installed_apps_only_returns_installed_mentions(tmp_path: Path) -> None:
manager = _manager(tmp_path)
manager._save_installed(
{
"gimp": {"entry_point": "cli-anything-gimp", "source": "harness"},
"zoom": {"entry_point": "cli-anything-zoom", "source": "public"},
}
)
mentions = manager.mentioned_installed_apps("use @zoom and @krita, then @GIMP")
assert mentions == [
{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"source": "public",
"skill": "skills/cli-app-zoom/SKILL.md",
"tool": "run_cli_app",
},
{
"name": "gimp",
"entry_point": "cli-anything-gimp",
"source": "harness",
"skill": "skills/cli-app-gimp/SKILL.md",
"tool": "run_cli_app",
},
]
def test_install_rejects_unknown_and_script_strategy(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
with pytest.raises(CliAppError, match="not found"):
manager.install("missing")
with pytest.raises(CliAppError, match="unsupported"):
manager.install("jimeng")
def test_run_installed_cli_uses_argv_without_shell(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert "shell" not in kwargs or kwargs["shell"] is False
return subprocess.CompletedProcess(
argv,
0,
stdout="ARGS=" + repr(argv[1:]),
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed(
{
"gimp": {
"version": "1.0.0",
"entry_point": "cli-anything-gimp",
"source": "harness",
"strategy": "pip",
}
}
)
result = manager.run("gimp", ["project", "list"], json_output=True)
assert "CLI app 'gimp' exited 0" in result
assert "['--json', 'project', 'list']" in result
def test_run_reports_created_artifacts(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
cwd = Path(str(kwargs["cwd"]))
(cwd / "diagram.png").write_bytes(b"\x89PNG\r\n\x1a\nimage")
return subprocess.CompletedProcess(argv, 0, stdout="done", stderr="")
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
result = manager.run("gimp", ["render"])
assert "Artifacts created or updated:" in result
assert "diagram.png (previewable image" in result
assert "![diagram](diagram.png)" in result
def test_run_blocks_working_dir_outside_workspace(tmp_path: Path) -> None:
manager = _manager(tmp_path)
_seed_catalog(manager)
manager._save_installed({"gimp": {"entry_point": "cli-anything-gimp"}})
with pytest.raises(CliAppError, match="outside the configured workspace"):
manager.run("gimp", working_dir="/etc", restrict_to_workspace=True)
-125
View File
@@ -1,125 +0,0 @@
from __future__ import annotations
import asyncio
import json
import subprocess
import time
from pathlib import Path
from nanobot.agent.tools.cli_apps import CliAppsTool
from nanobot.cli_apps.service import CliAppManager, CliAppsRuntimeConfig
def _write_cache(path: Path, registry: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps({"_cached_at": time.time(), "data": registry}),
encoding="utf-8",
)
def test_run_cli_app_uses_installed_registry_app(
tmp_path: Path,
monkeypatch,
) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"gimp": {"entry_point": "cli-anything-gimp"}}
)
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
monkeypatch.setattr(
"nanobot.cli_apps.service.shutil.which",
lambda entry: resolved if entry == "cli-anything-gimp" else None,
)
def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert "shell" not in kwargs or kwargs["shell"] is False
return subprocess.CompletedProcess(
argv,
0,
stdout="tool:" + " ".join(argv[1:]),
stderr="",
)
monkeypatch.setattr("nanobot.cli_apps.service.subprocess.run", fake_run)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(
workspace=workspace,
restrict_to_workspace=True,
runtime=CliAppsRuntimeConfig(run_timeout=5),
)
assert tool.name == "run_cli_app"
result = asyncio.run(
tool.execute(
name="gimp",
args=["project", "list"],
json=True,
working_dir=str(workspace),
)
)
assert "CLI app 'gimp' exited 0" in result
assert "tool:--json project list" in result
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
registry = {
"meta": {"updated": "2026-04-16"},
"clis": [
{
"name": "gimp",
"display_name": "GIMP",
"version": "1.0.0",
"description": "Image editing",
"category": "image",
"install_cmd": "pip install cli-anything-gimp",
"entry_point": "cli-anything-gimp",
}
],
}
_write_cache(data_dir / "harness_registry_cache.json", registry)
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
result = asyncio.run(tool.execute(name="gimp"))
assert "not installed" in result
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
workspace = tmp_path / "workspace"
workspace.mkdir()
data_dir = tmp_path / "data"
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
{"drawio": {"entry_point": "cli-anything-drawio"}}
)
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
tool = CliAppsTool(workspace=workspace)
assert "Settings CLI Apps: drawio" in tool.description
assert "ordinary system CLIs such as git, gh" in tool.description
-64
View File
@@ -1,64 +0,0 @@
"""Tests for CLI Apps loop helpers."""
from types import SimpleNamespace
from nanobot.cli_apps.service import CliAppManager
from nanobot.cli_apps.utils import runtime_lines, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
cli_apps = [{"name": "zoom"}]
assert session_extra({"cli_apps": cli_apps}) == {"cli_apps": cli_apps}
assert session_extra({}) == {}
assert session_extra(None) == {}
def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
data_dir = tmp_path / "data"
monkeypatch.setattr("nanobot.cli_apps.service.get_runtime_subdir", lambda _name: data_dir)
manager = CliAppManager(workspace=tmp_path)
manager._save_installed(
{
"zoom": {
"entry_point": "cli-anything-zoom",
"source": "harness",
},
"krita": {
"entry_point": "cli-anything-krita",
"source": "harness",
},
}
)
lines = runtime_lines(
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Mention: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" 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={
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
),
tmp_path,
)
joined = "\n".join(lines)
assert "CLI App Attachment: @zoom" in joined
assert "tool=run_cli_app" in joined
assert "entry_point=cli-anything-zoom" in joined
assert "skill=skills/cli-app-zoom/SKILL.md" in joined
-17
View File
@@ -192,20 +192,3 @@ def test_match_provider_uses_preset_provider_when_forced() -> None:
})
name = config.get_provider_name()
assert name == "anthropic"
def test_match_provider_routes_forced_novita_model_api_models() -> None:
config = Config.model_validate({
"providers": {
"novita": {"apiKey": "sk-test"},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
"provider": "novita",
}
},
})
assert config.get_provider_name() == "novita"
assert config.get_api_base() == "https://api.novita.ai/openai"
-29
View File
@@ -56,35 +56,6 @@ def test_custom_provider_parse_chunks_accepts_plain_text_chunks() -> None:
assert result.content == "hello world"
def test_custom_provider_parse_chunks_deduplicates_parallel_tool_call_ids() -> None:
chunks = [{
"choices": [{
"finish_reason": "tool_calls",
"delta": {
"tool_calls": [
{
"index": 0,
"id": "call_dup",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
},
{
"index": 1,
"id": "call_dup",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
},
],
},
}],
}]
result = OpenAICompatProvider._parse_chunks(chunks)
ids = [tool_call.id for tool_call in result.tool_calls or []]
assert ids[0] == "call_dup"
assert len(ids) == 2
assert len(set(ids)) == 2
def test_local_provider_502_error_includes_reachability_hint() -> None:
spec = find_by_name("ollama")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
-512
View File
@@ -9,13 +9,10 @@ import pytest
from nanobot.providers.image_generation import (
AIHubMixImageGenerationClient,
CodexImageGenerationClient,
GeminiImageGenerationClient,
GeneratedImageResponse,
ImageGenerationError,
MiniMaxImageGenerationClient,
OllamaImageGenerationClient,
OpenAIImageGenerationClient,
OpenRouterImageGenerationClient,
StepFunImageGenerationClient,
)
@@ -39,14 +36,12 @@ class FakeResponse:
payload: dict[str, Any],
status_code: int = 200,
content: bytes = b"",
sse_lines: list[str] | None = None,
) -> None:
self._payload = payload
self.status_code = status_code
self.text = str(payload)
self.content = content
self.request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
self._sse_lines = sse_lines
def json(self) -> dict[str, Any]:
return self._payload
@@ -56,15 +51,6 @@ class FakeResponse:
response = httpx.Response(self.status_code, request=self.request, text=self.text)
raise httpx.HTTPStatusError("failed", request=self.request, response=response)
async def aiter_lines(self):
if self._sse_lines is not None:
for line in self._sse_lines:
yield line
return
# Fallback: treat response text as SSE lines
for line in self.text.split("\n"):
yield line
class FakeClient:
def __init__(self, response: FakeResponse) -> None:
@@ -147,54 +133,6 @@ async def test_openrouter_image_generation_requires_api_key() -> None:
await client.generate(prompt="draw", model="model")
@pytest.mark.asyncio
async def test_ollama_image_generation_payload_and_response() -> None:
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
fake = FakeClient(FakeResponse({"image": raw_b64}))
client = OllamaImageGenerationClient(
api_key="ollama-test",
api_base="http://localhost:11434/v1/",
extra_headers={"X-Test": "1"},
extra_body={"seed": 123},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a sunset",
model="x/z-image-turbo",
aspect_ratio="16:9",
image_size="1K",
)
assert response.images == [PNG_DATA_URL]
assert response.content == ""
call = fake.calls[0]
assert call["url"] == "http://localhost:11434/api/generate"
assert call["headers"]["Authorization"] == "Bearer ollama-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "x/z-image-turbo"
assert body["prompt"] == "a sunset"
assert body["width"] == 1024
assert body["height"] == 576
assert body["steps"] == 0
assert body["stream"] is False
assert body["seed"] == 123
@pytest.mark.asyncio
async def test_ollama_image_generation_rejects_reference_images() -> None:
client = OllamaImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="reference images"):
await client.generate(
prompt="edit this",
model="x/z-image-turbo",
reference_images=["ref.png"],
)
@pytest.mark.asyncio
async def test_aihubmix_image_generation_payload_and_response() -> None:
raw_b64 = PNG_DATA_URL.removeprefix("data:image/png;base64,")
@@ -410,11 +348,6 @@ async def test_gemini_requires_api_key() -> None:
await client.generate(prompt="draw", model="imagen-4.0-generate-001")
def test_gemini_image_client_uses_native_api_base_by_default() -> None:
client = GeminiImageGenerationClient(api_key="AIza-test")
assert client.api_base == "https://generativelanguage.googleapis.com/v1beta"
@pytest.mark.asyncio
async def test_gemini_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"candidates": [{"content": {"parts": [{"text": "sorry"}]}}]}))
@@ -457,17 +390,6 @@ async def test_minimax_payload_and_response_with_reference_image(tmp_path: Path)
assert body["subject_reference"][0]["image_file"].startswith("data:image/png;base64,")
@pytest.mark.asyncio
async def test_minimax_base64_response_uses_detected_mime() -> None:
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
fake = FakeClient(FakeResponse({"data": {"image_base64": [raw_b64]}}))
client = MiniMaxImageGenerationClient(api_key="sk-mm-test", client=fake) # type: ignore[arg-type]
response = await client.generate(prompt="draw", model="image-01")
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
# ---------------------------------------------------------------------------
# StepFun (阶跃星辰)
# ---------------------------------------------------------------------------
@@ -593,437 +515,3 @@ async def test_stepfun_no_images_raises() -> None:
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="step-image-edit-2")
# ---------------------------------------------------------------------------
# OpenAI
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_openai_payload_and_response() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
api_base="https://api.openai.com/v1",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="a cat on the moon",
model="dall-e-3",
aspect_ratio="16:9",
)
assert response.images == [PNG_DATA_URL]
call = fake.calls[0]
assert call["url"] == "https://api.openai.com/v1/images/generations"
assert call["headers"]["Authorization"] == "Bearer sk-openai-test"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "dall-e-3"
assert body["prompt"] == "a cat on the moon"
assert body["response_format"] == "b64_json"
assert body["n"] == 1
assert body["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_b64_json_response_uses_detected_mime() -> None:
raw_b64 = base64.b64encode(JPEG_BYTES).decode("ascii")
fake = FakeClient(FakeResponse({"data": [{"b64_json": raw_b64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert response.images == [f"data:image/jpeg;base64,{raw_b64}"]
@pytest.mark.asyncio
async def test_openai_url_download_fallback() -> None:
fake = FakeClient(FakeResponse({"data": [{"url": "https://cdn.example/image.png"}]}))
fake.get_response = FakeResponse({}, content=PNG_BYTES)
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert response.images[0].startswith("data:image/png;base64,")
assert fake.get_calls[0]["url"] == "https://cdn.example/image.png"
@pytest.mark.asyncio
async def test_openai_multiple_images() -> None:
fake = FakeClient(FakeResponse({
"data": [
{"b64_json": RAW_B64},
{"b64_json": RAW_B64},
]
}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="dall-e-3")
assert len(response.images) == 2
assert response.images == [PNG_DATA_URL, PNG_DATA_URL]
@pytest.mark.asyncio
async def test_openai_aspect_ratio_to_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="1:1")
assert fake.calls[0]["json"]["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_dalle3_uses_supported_orientation_sizes() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="3:4")
await client.generate(prompt="draw", model="dall-e-3", aspect_ratio="4:3")
assert fake.calls[0]["json"]["size"] == "1024x1792"
assert fake.calls[1]["json"]["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_dalle2_uses_square_size_for_non_square_ratios() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-2", aspect_ratio="16:9")
assert fake.calls[0]["json"]["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_gpt_image_uses_supported_landscape_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="16:9")
assert fake.calls[0]["json"]["size"] == "1536x1024"
@pytest.mark.asyncio
async def test_openai_gpt_image_uses_supported_orientation_sizes() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="3:4")
await client.generate(prompt="draw", model="gpt-image-1", aspect_ratio="4:3")
assert fake.calls[0]["json"]["size"] == "1024x1536"
assert fake.calls[1]["json"]["size"] == "1536x1024"
@pytest.mark.asyncio
async def test_openai_default_size_when_no_aspect_ratio() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="dall-e-3")
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_ignores_explicit_size_unsupported_by_model_family() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="draw",
model="dall-e-3",
aspect_ratio="16:9",
image_size="1536x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1792x1024"
@pytest.mark.asyncio
async def test_openai_uses_explicit_image_size() -> None:
fake = FakeClient(FakeResponse({"data": [{"b64_json": RAW_B64}]}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
await client.generate(
prompt="draw",
model="dall-e-3",
aspect_ratio="16:9",
image_size="1024x1024",
)
body = fake.calls[0]["json"]
assert body["size"] == "1024x1024"
@pytest.mark.asyncio
async def test_openai_requires_api_key() -> None:
client = OpenAIImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="API key"):
await client.generate(prompt="draw", model="dall-e-3")
# ---------------------------------------------------------------------------
# OpenAI Codex (Responses API)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_codex_payload_and_response(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
sse_lines = [
'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}',
"",
f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1","type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}',
"",
'data: [DONE]',
"",
]
fake = FakeClient(FakeResponse({}, sse_lines=sse_lines))
client = CodexImageGenerationClient(
api_key=None,
api_base="https://chatgpt.com/backend-api",
extra_headers={"X-Test": "1"},
client=fake, # type: ignore[arg-type]
)
response = await client.generate(
prompt="draw a cat",
model="gpt-5.4",
)
assert response.images == [PNG_DATA_URL]
assert response.content == ""
call = fake.calls[0]
assert call["url"] == "https://chatgpt.com/backend-api/codex/responses"
assert call["headers"]["Authorization"] == "Bearer oauth-token"
assert call["headers"]["chatgpt-account-id"] == "acct-123"
assert call["headers"]["OpenAI-Beta"] == "responses=experimental"
assert call["headers"]["X-Test"] == "1"
body = call["json"]
assert body["model"] == "gpt-5.4"
assert body["instructions"] == "Generate an image based on the user's request."
assert body["input"] == [{"role": "user", "content": "draw a cat"}]
assert body["tools"] == [{"type": "image_generation"}]
assert body["tool_choice"] == "auto"
assert body["store"] is False
assert body["stream"] is True
@pytest.mark.asyncio
async def test_codex_strips_model_prefix(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
await client.generate(prompt="draw", model="openai-codex/gpt-5.4")
assert fake.calls[0]["json"]["model"] == "gpt-5.4"
@pytest.mark.asyncio
async def test_codex_requires_oauth(monkeypatch) -> None:
async def fake_to_thread(fn, *args, **kwargs):
raise RuntimeError("no token")
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
client = CodexImageGenerationClient(api_key=None)
with pytest.raises(ImageGenerationError, match="OAuth token"):
await client.generate(prompt="draw", model="gpt-5.4")
@pytest.mark.asyncio
async def test_codex_no_images_raises(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
'data: {"type":"response.completed","response":{"status":"completed"}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="gpt-5.4")
@pytest.mark.asyncio
async def test_codex_extracts_text_content(monkeypatch) -> None:
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
'data: {"type":"response.output_text.delta","delta":"Here "}',
"",
'data: {"type":"response.output_text.delta","delta":"is your cat image."}',
"",
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":"{PNG_DATA_URL}"}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
response = await client.generate(prompt="draw a cat", model="gpt-5.4")
assert response.images == [PNG_DATA_URL]
assert response.content == "Here is your cat image."
@pytest.mark.asyncio
async def test_codex_json_result_format(monkeypatch) -> None:
"""image_generation_call result can be a dict with image_url key."""
import sys
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeToken:
account_id: str = "acct-123"
access: str = "oauth-token"
async def fake_to_thread(fn, *args, **kwargs):
return fn(*args, **kwargs)
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
fake = FakeClient(FakeResponse({}, sse_lines=[
f'data: {{"type":"response.output_item.done","item":{{"type":"image_generation_call","result":{{"image_url":"{PNG_DATA_URL}"}}}}}}',
"",
'data: [DONE]',
"",
]))
client = CodexImageGenerationClient(
api_key=None, client=fake # type: ignore[arg-type]
)
response = await client.generate(prompt="draw", model="gpt-5.4")
assert response.images == [PNG_DATA_URL]
@pytest.mark.asyncio
async def test_openai_no_images_raises() -> None:
fake = FakeClient(FakeResponse({"data": []}))
client = OpenAIImageGenerationClient(
api_key="sk-openai-test",
client=fake, # type: ignore[arg-type]
)
with pytest.raises(ImageGenerationError, match="returned no images"):
await client.generate(prompt="draw", model="dall-e-3")
+4 -74
View File
@@ -441,15 +441,6 @@ def test_openrouter_spec_is_gateway() -> None:
assert spec.default_api_base == "https://openrouter.ai/api/v1"
def test_novita_spec_uses_openai_compatible_gateway() -> None:
spec = find_by_name("novita")
assert spec is not None
assert spec.is_gateway is True
assert spec.backend == "openai_compat"
assert spec.env_key == "NOVITA_API_KEY"
assert spec.default_api_base == "https://api.novita.ai/openai"
def test_gemma_routes_to_gemini_provider() -> None:
"""gemma models (e.g. gemma-3-27b-it) must auto-route to Gemini when GEMINI_API_KEY is set.
Users running gemma via the Gemini API endpoint expect automatic provider detection."""
@@ -458,12 +449,6 @@ def test_gemma_routes_to_gemini_provider() -> None:
assert "gemma" in spec.keywords
def test_gemini_spec_keeps_openai_compat_base() -> None:
spec = find_by_name("gemini")
assert spec is not None
assert spec.default_api_base == "https://generativelanguage.googleapis.com/v1beta/openai/"
async def test_openrouter_sets_default_attribution_headers() -> None:
spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_cls:
@@ -1022,41 +1007,6 @@ def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -
assert sanitized[2]["tool_call_id"] == "3ec83c30d"
def test_openai_compat_deduplicates_duplicate_tool_call_ids_in_history() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
sanitized = provider._sanitize_messages([
{"role": "user", "content": "check both files"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "ab1b45c2a",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"a.txt"}'},
},
{
"id": "ab1b45c2a",
"type": "function",
"function": {"name": "read_file", "arguments": '{"path":"b.txt"}'},
},
],
},
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "a"},
{"role": "tool", "tool_call_id": "ab1b45c2a", "name": "read_file", "content": "b"},
{"role": "user", "content": "continue"},
])
tool_call_ids = [tc["id"] for tc in sanitized[1]["tool_calls"]]
tool_result_ids = [sanitized[2]["tool_call_id"], sanitized[3]["tool_call_id"]]
assert tool_call_ids[0] == "ab1b45c2a"
assert len(tool_call_ids) == len(set(tool_call_ids)) == 2
assert tool_result_ids == tool_call_ids
def test_openai_compat_stringifies_dict_tool_arguments() -> None:
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider()
@@ -1426,15 +1376,12 @@ def test_kimi_k25_thinking_enabled() -> None:
"""kimi-k2.5 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
# Moonshot rejects both 'reasoning_effort' and 'thinking' (#3939)
assert "reasoning_effort" not in kw
def test_kimi_k25_thinking_disabled_for_minimal() -> None:
"""reasoning_effort='minimal' maps to thinking disabled for kimi-k2.5."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="minimal")
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
@@ -1444,36 +1391,21 @@ def test_kimi_k25_no_extra_body_when_reasoning_effort_none() -> None:
def test_kimi_k25_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking.
OR drops upstream-provider `thinking` fields, so the same intent also has
to go through OR's `reasoning.effort` shape (#3851 follow-up).
"""
"""OpenRouter-style model names like moonshotai/kimi-k2.5 must trigger thinking."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.5", reasoning_effort="medium")
assert kw.get("extra_body") == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
# Even via OR, reasoning_effort wire kwarg is dropped for kimi models
assert "reasoning_effort" not in kw
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_kimi_k26_thinking_enabled() -> None:
"""kimi-k2.6 with reasoning_effort set should opt in to thinking."""
kw = _build_kwargs_for("moonshot", "kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k26_thinking_enabled_with_openrouter_prefix() -> None:
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking
via both upstream `thinking` and OR's `reasoning.effort`."""
"""OpenRouter-style names like moonshotai/kimi-k2.6 must trigger thinking."""
kw = _build_kwargs_for("openrouter", "moonshotai/kimi-k2.6", reasoning_effort="medium")
assert kw.get("extra_body") == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
assert "reasoning_effort" not in kw
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
def test_moonshot_kimi_k26_temperature_override() -> None:
@@ -1492,7 +1424,6 @@ def test_kimi_k26_code_preview_thinking_enabled() -> None:
"""k2.6-code-preview also supports thinking; should behave like k2.5."""
kw = _build_kwargs_for("moonshot", "k2.6-code-preview", reasoning_effort="high")
assert kw.get("extra_body") == {"thinking": {"type": "enabled"}}
assert "reasoning_effort" not in kw
def test_kimi_k2_series_no_thinking_injection() -> None:
@@ -1522,7 +1453,6 @@ def test_kimi_k25_thinking_disabled_for_none_string() -> None:
"""reasoning_effort='none' maps to thinking disabled for kimi-k2.5."""
kw = _build_kwargs_for("moonshot", "kimi-k2.5", reasoning_effort="none")
assert kw.get("extra_body") == {"thinking": {"type": "disabled"}}
assert "reasoning_effort" not in kw
def test_dashscope_thinking_disabled_for_none_string() -> None:
-97
View File
@@ -1,97 +0,0 @@
"""Tests for the Novita AI provider registration."""
from unittest.mock import patch
from nanobot.config.schema import Config, ProvidersConfig
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
from nanobot.providers.registry import PROVIDERS, find_by_name
def test_novita_config_field_exists() -> None:
config = ProvidersConfig()
assert hasattr(config, "novita")
def test_novita_provider_in_registry() -> None:
specs = {spec.name: spec for spec in PROVIDERS}
assert "novita" in specs
novita = specs["novita"]
assert novita.backend == "openai_compat"
assert novita.env_key == "NOVITA_API_KEY"
assert novita.display_name == "Novita AI"
assert novita.is_gateway is True
assert novita.detect_by_base_keyword == "novita"
assert novita.default_api_base == "https://api.novita.ai/openai"
assert novita.strip_model_prefix is False
def test_find_by_name_novita() -> None:
spec = find_by_name("novita")
assert spec is not None
assert spec.name == "novita"
def test_novita_forced_provider_uses_default_api_base() -> None:
config = Config.model_validate({
"providers": {
"novita": {
"apiKey": "novita-key",
},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
"provider": "novita",
},
},
})
assert config.get_provider_name("deepseek-v4-pro") == "novita"
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
def test_novita_gateway_routes_unprefixed_models_when_configured() -> None:
config = Config.model_validate({
"providers": {
"novita": {
"apiKey": "novita-key",
},
},
"agents": {
"defaults": {
"model": "deepseek-v4-pro",
},
},
})
assert config.get_provider_name("deepseek-v4-pro") == "novita"
assert config.get_api_key("deepseek-v4-pro") == "novita-key"
assert config.get_api_base("deepseek-v4-pro") == "https://api.novita.ai/openai"
def test_novita_preserves_model_api_id() -> None:
spec = find_by_name("novita")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"):
provider = OpenAICompatProvider(
api_key="novita-key",
default_model="deepseek-v4-pro",
spec=spec,
)
kwargs = provider._build_kwargs(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="deepseek-v4-pro",
max_tokens=1024,
temperature=0.7,
reasoning_effort=None,
tool_choice=None,
)
assert kwargs["model"] == "deepseek-v4-pro"
assert kwargs["max_tokens"] == 1024
assert "max_completion_tokens" not in kwargs
-43
View File
@@ -155,49 +155,6 @@ class TestConvertMessages:
assert items[0]["id"] == "fc_1"
assert items[0]["name"] == "get_weather"
def test_duplicate_response_item_ids_are_made_unique(self):
"""Codex rejects replayed Responses input items with duplicate ids."""
_, items = convert_messages([
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_a|rs_same",
"function": {"name": "first", "arguments": "{}"},
}],
},
{"role": "tool", "tool_call_id": "call_a|rs_same", "content": "ok"},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_b|rs_same",
"function": {"name": "second", "arguments": "{}"},
}],
},
{"role": "tool", "tool_call_id": "call_b|rs_same", "content": "ok"},
])
function_call_ids = [
item["id"] for item in items if item.get("type") == "function_call"
]
assert function_call_ids == ["rs_same", "rs_same_2"]
assert len(function_call_ids) == len(set(function_call_ids))
def test_fallback_response_item_ids_are_unique_with_multiple_tool_calls(self):
_, items = convert_messages([{
"role": "assistant",
"content": None,
"tool_calls": [
{"id": "call_a", "function": {"name": "first", "arguments": "{}"}},
{"id": "call_b", "function": {"name": "second", "arguments": "{}"}},
],
}])
function_call_ids = [
item["id"] for item in items if item.get("type") == "function_call"
]
assert function_call_ids == ["fc_0", "fc_0_2"]
assert len(function_call_ids) == len(set(function_call_ids))
def test_assistant_with_tool_calls_no_id(self):
"""Fallback IDs when tool_call.id is missing."""
_, items = convert_messages([{
+9
View File
@@ -12,6 +12,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_compat_provider", raising=False)
monkeypatch.delitem(sys.modules, "nanobot.providers.openai_codex_provider", raising=False)
monkeypatch.delitem(sys.modules, "nanobot.providers.github_copilot_provider", raising=False)
monkeypatch.delitem(sys.modules, "nanobot.providers.xai_oauth_provider", raising=False)
monkeypatch.delitem(sys.modules, "nanobot.providers.azure_openai_provider", raising=False)
monkeypatch.delitem(sys.modules, "nanobot.providers.bedrock_provider", raising=False)
@@ -21,6 +22,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
assert "nanobot.providers.openai_compat_provider" not in sys.modules
assert "nanobot.providers.openai_codex_provider" not in sys.modules
assert "nanobot.providers.github_copilot_provider" not in sys.modules
assert "nanobot.providers.xai_oauth_provider" not in sys.modules
assert "nanobot.providers.azure_openai_provider" not in sys.modules
assert "nanobot.providers.bedrock_provider" not in sys.modules
assert providers.__all__ == [
@@ -30,6 +32,7 @@ def test_importing_providers_package_is_lazy(monkeypatch) -> None:
"OpenAICompatProvider",
"OpenAICodexProvider",
"GitHubCopilotProvider",
"XaiOAuthProvider",
"AzureOpenAIProvider",
"BedrockProvider",
]
@@ -50,3 +53,9 @@ def test_openai_codex_supports_progress_deltas() -> None:
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
assert OpenAICodexProvider.supports_progress_deltas is True
def test_xai_oauth_supports_progress_deltas() -> None:
from nanobot.providers.xai_oauth_provider import XaiOAuthProvider
assert XaiOAuthProvider.supports_progress_deltas is True
+146
View File
@@ -0,0 +1,146 @@
from __future__ import annotations
import json
import os
import stat
from urllib.parse import parse_qs, urlparse
import pytest
import nanobot.providers.xai_oauth_provider as auth
def test_build_xai_authorization_url_includes_pkce_and_grok_scope() -> None:
endpoints = auth.XaiOAuthEndpoints(
authorization_endpoint="https://auth.x.ai/authorize",
token_endpoint="https://auth.x.ai/oauth/token",
)
url = auth.build_xai_authorization_url(
endpoints,
verifier="verifier",
state="state",
nonce="nonce",
)
parsed = urlparse(url)
params = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.hostname == "auth.x.ai"
assert params["client_id"] == [auth.DEFAULT_XAI_CLIENT_ID]
assert params["code_challenge"] == [auth.pkce_challenge("verifier")]
assert params["code_challenge_method"] == ["S256"]
assert params["scope"] == [auth.DEFAULT_XAI_SCOPE]
assert params["nonce"] == ["nonce"]
assert params["plan"] == ["generic"]
assert params["referrer"] == ["nanobot"]
def test_parse_callback_value_accepts_fallback_shapes() -> None:
assert auth._parse_callback_value("https://localhost/callback?code=abc&state=state") == ("abc", "state")
assert auth._parse_callback_value("?code=abc&state=state") == ("abc", "state")
assert auth._parse_callback_value("code=abc&state=state") == ("abc", "state")
assert auth._parse_callback_value("fallback-code") == ("fallback-code", None)
def test_file_storage_fallback_is_private_and_round_trips(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
monkeypatch.setattr(auth, "_keyring_set", lambda _tokens: False)
monkeypatch.setattr(auth, "_keyring_get", lambda: None)
saved = auth.save_xai_oauth_credential(
auth.XaiOAuthCredential(
access_token="access",
refresh_token="refresh",
expires_at=123.0,
account_id="acct",
)
)
path = auth.get_xai_oauth_metadata_path()
payload = json.loads(path.read_text(encoding="utf-8"))
assert saved.storage == "file"
assert payload["storage"] == "file"
assert payload["tokens"]["access_token"] == "access"
if os.name != "nt":
assert stat.S_IMODE(path.stat().st_mode) == 0o600
loaded = auth.load_xai_oauth_credential()
assert loaded is not None
assert loaded.access_token == "access"
assert loaded.refresh_token == "refresh"
assert loaded.account_id == "acct"
assert loaded.storage == "file"
def test_keyring_storage_keeps_tokens_out_of_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("NANOBOT_HOME", str(tmp_path))
secret: dict[str, object] = {}
def fake_set(tokens: dict[str, object]) -> bool:
secret.update(tokens)
return True
monkeypatch.setattr(auth, "_keyring_set", fake_set)
monkeypatch.setattr(auth, "_keyring_get", lambda: dict(secret))
auth.save_xai_oauth_credential(
auth.XaiOAuthCredential(
access_token="access",
refresh_token="refresh",
expires_at=123.0,
account_id="acct",
)
)
payload = json.loads(auth.get_xai_oauth_metadata_path().read_text(encoding="utf-8"))
assert payload["storage"] == "keyring"
assert "tokens" not in payload
assert auth.load_xai_oauth_credential().access_token == "access"
def test_exchange_xai_oauth_code_sends_required_code_challenge(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeResponse:
status_code = 200
text = ""
def json(self) -> dict[str, object]:
return {"access_token": "access", "refresh_token": "refresh", "expires_in": 3600}
class FakeClient:
def __init__(self, *args, **kwargs) -> None:
pass
def __enter__(self):
return self
def __exit__(self, *args) -> None:
pass
def post(self, url: str, headers: dict[str, str], data: dict[str, str]) -> FakeResponse:
captured["url"] = url
captured["headers"] = headers
captured["data"] = data
return FakeResponse()
monkeypatch.setattr(auth.httpx, "Client", FakeClient)
endpoints = auth.XaiOAuthEndpoints(
authorization_endpoint="https://auth.x.ai/authorize",
token_endpoint="https://auth.x.ai/oauth/token",
)
credential = auth.exchange_xai_oauth_code("code", verifier="verifier", endpoints=endpoints)
assert credential.access_token == "access"
assert captured["url"] == "https://auth.x.ai/oauth/token"
data = captured["data"]
assert data["code_verifier"] == "verifier"
assert data["code_challenge"] == auth.pkce_challenge("verifier")
assert data["code_challenge_method"] == "S256"
def test_rejects_non_xai_discovery_endpoints() -> None:
with pytest.raises(RuntimeError):
auth._validate_xai_endpoint("https://example.com/oauth/token", "token_endpoint")
+141
View File
@@ -0,0 +1,141 @@
from __future__ import annotations
import asyncio
from nanobot.config.schema import XaiOAuthXSearchConfig
import nanobot.providers.xai_oauth_provider as xai_oauth_provider
from nanobot.providers.xai_oauth_provider import (
XaiOAuthCredential,
XaiOAuthProvider,
_build_xai_responses_body,
_strip_model_prefix,
)
def test_xai_oauth_strip_prefix_supports_aliases() -> None:
assert _strip_model_prefix("xai-oauth/grok-4.3") == "grok-4.3"
assert _strip_model_prefix("xai_oauth/grok-4.3") == "grok-4.3"
assert _strip_model_prefix("grok-oauth/grok-4.3") == "grok-4.3"
assert _strip_model_prefix("grok-4.3") == "grok-4.3"
def test_build_xai_responses_body_keeps_system_prompt_in_input() -> None:
body = _build_xai_responses_body(
messages=[
{"role": "system", "content": "You are nanobot."},
{"role": "user", "content": "hi"},
],
tools=[
{
"type": "function",
"function": {
"name": "ping",
"description": "Ping",
"parameters": {"type": "object", "properties": {}},
},
}
],
model="xai-oauth/grok-4.3",
max_tokens=32,
temperature=0.2,
reasoning_effort="high",
tool_choice=None,
)
assert body["model"] == "grok-4.3"
assert "instructions" not in body
assert body["input"][0] == {
"role": "system",
"content": [{"type": "input_text", "text": "You are nanobot."}],
}
assert body["input"][1]["role"] == "user"
assert body["max_output_tokens"] == 32
assert body["temperature"] == 0.2
assert body["reasoning"] == {"effort": "high"}
assert body["tools"][0]["name"] == "ping"
def test_build_xai_responses_body_attaches_hosted_x_search_by_default() -> None:
body = _build_xai_responses_body(
messages=[{"role": "user", "content": "what is happening on X?"}],
tools=None,
model="xai-oauth/grok-4.3",
max_tokens=32,
temperature=0.2,
reasoning_effort=None,
tool_choice=None,
hosted_x_search=XaiOAuthXSearchConfig(),
)
assert body["tools"] == [{"type": "x_search"}]
def test_build_xai_responses_body_can_customize_hosted_x_search() -> None:
body = _build_xai_responses_body(
messages=[{"role": "user", "content": "what is happening on X?"}],
tools=None,
model="xai-oauth/grok-4.3",
max_tokens=32,
temperature=0.2,
reasoning_effort=None,
tool_choice=None,
hosted_x_search=XaiOAuthXSearchConfig(
allowed_x_handles=["@xai", " nanobot "],
enable_image_understanding=True,
),
)
assert body["tools"] == [
{
"type": "x_search",
"allowed_x_handles": ["xai", "nanobot"],
"enable_image_understanding": True,
}
]
def test_build_xai_responses_body_omits_disabled_hosted_x_search() -> None:
body = _build_xai_responses_body(
messages=[{"role": "user", "content": "hi"}],
tools=None,
model="xai-oauth/grok-4.3",
max_tokens=32,
temperature=0.2,
reasoning_effort=None,
tool_choice=None,
hosted_x_search=XaiOAuthXSearchConfig(enable=False),
)
assert "tools" not in body
def test_xai_oauth_provider_refreshes_once_on_401(monkeypatch) -> None:
async def run() -> None:
response = await provider.chat([{"role": "user", "content": "hi"}])
assert response.content == "ok"
assert response.finish_reason == "stop"
assert calls == [("resolve", False), ("resolve", True)]
provider = XaiOAuthProvider(default_model="xai-oauth/grok-4.3")
credentials = [
XaiOAuthCredential(access_token="expired"),
XaiOAuthCredential(access_token="fresh"),
]
calls: list[tuple[str, bool]] = []
def fake_resolve(*, force_refresh: bool = False) -> XaiOAuthCredential:
calls.append(("resolve", force_refresh))
return credentials.pop(0)
async def fake_request(credential, body, on_content_delta=None, on_tool_call_delta=None):
from nanobot.providers.xai_oauth_provider import _XaiHTTPError
if credential.access_token == "expired":
raise _XaiHTTPError("expired", status_code=401)
return "ok", [], "stop"
monkeypatch.setattr(xai_oauth_provider, "resolve_xai_oauth_credential", fake_resolve)
monkeypatch.setattr(xai_oauth_provider, "_request_xai", fake_request)
asyncio.run(run())
+9 -44
View File
@@ -32,7 +32,7 @@ def _mimo_spec():
def _openrouter_spec():
"""Return the registered OpenRouter ProviderSpec."""
"""Return the registered OpenRouter ProviderSpec (no thinking_style)."""
specs = {s.name: s for s in PROVIDERS}
return specs["openrouter"]
@@ -77,13 +77,6 @@ def test_xiaomi_mimo_uses_thinking_type_style():
assert spec.default_api_base == "https://api.xiaomimimo.com/v1"
def test_openrouter_declares_gateway_reasoning_style():
"""OpenRouter uses its own reasoning.effort field for routed thinking models."""
spec = _openrouter_spec()
assert spec.thinking_style == ""
assert spec.gateway_reasoning_style == "reasoning_effort"
# ---------------------------------------------------------------------------
# _build_kwargs wire-format
# ---------------------------------------------------------------------------
@@ -149,11 +142,9 @@ def test_mimo_reasoning_effort_unset_preserves_provider_default():
def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro" and does NOT forward
extra_body.thinking to upstream, so a disable signal must also reach OR
in its own `reasoning.effort` shape. Verifies both the upstream-MiMo
payload (#3845) and the OR-native payload (#3851 follow-up) are sent.
"""
"""OpenRouter routes MiMo as "xiaomi/mimo-v2.5-pro"; the openrouter spec
has no thinking_style, so the disable signal must come from the
model-name path (#3845)."""
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -161,15 +152,11 @@ def test_mimo_via_openrouter_reasoning_effort_none_disables_thinking():
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert "reasoning_effort" not in kwargs
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
"""Non-none/minimal effort enables thinking and the OR `reasoning.effort`
field mirrors the requested effort level."""
"""Same as the direct path: any non-none/minimal effort enables thinking."""
provider = _openrouter_provider("xiaomi/mimo-v2.5-pro")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -177,10 +164,7 @@ def test_mimo_via_openrouter_reasoning_effort_medium_enables_thinking():
temperature=0.7, reasoning_effort="medium", tool_choice=None,
)
assert kwargs.get("reasoning_effort") == "medium"
assert kwargs["extra_body"] == {
"thinking": {"type": "enabled"},
"reasoning": {"effort": "medium"},
}
assert kwargs["extra_body"] == {"thinking": {"type": "enabled"}}
def test_mimo_via_openrouter_bare_slug_also_matches():
@@ -192,16 +176,12 @@ def test_mimo_via_openrouter_bare_slug_also_matches():
tools=None, model=None, max_tokens=100,
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
assert kwargs["extra_body"] == {"thinking": {"type": "disabled"}}
def test_mimo_flash_via_openrouter_does_not_inject_thinking():
"""mimo-v2-flash has no thinking mode per Xiaomi docs; the allowlist
excludes it, so neither the upstream `thinking` field nor OR's
`reasoning.effort` should be injected on the gateway path."""
excludes it, so no thinking field should be injected on the gateway path."""
provider = _openrouter_provider("xiaomi/mimo-v2-flash")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
@@ -220,18 +200,3 @@ def test_non_mimo_model_via_openrouter_unaffected():
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert "extra_body" not in kwargs
def test_kimi_via_openrouter_also_injects_reasoning_effort():
"""Kimi has the same gateway problem as MiMo: OR drops the upstream
`thinking` field. The same OR-reasoning injection should fire."""
provider = _openrouter_provider("moonshotai/kimi-k2.5")
kwargs = provider._build_kwargs(
messages=_simple_messages(),
tools=None, model=None, max_tokens=100,
temperature=0.7, reasoning_effort="none", tool_choice=None,
)
assert kwargs["extra_body"] == {
"thinking": {"type": "disabled"},
"reasoning": {"effort": "none"},
}
-330
View File
@@ -1,330 +0,0 @@
from __future__ import annotations
import asyncio
from nanobot.agent.tools.apply_patch import ApplyPatchTool
def test_apply_patch_edits_replace(tmp_path):
target = tmp_path / "calc.py"
target.write_text("def add(a, b):\n return a + b\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "calc.py",
"action": "replace",
"old_text": " return a + b",
"new_text": " return a - b",
}
]
)
)
assert "update calc.py" in result
assert target.read_text() == "def add(a, b):\n return a - b\n"
def test_apply_patch_edits_add_new_file(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "config.py",
"action": "add",
"new_text": "DEBUG = True",
}
]
)
)
assert "add config.py" in result
assert (tmp_path / "config.py").read_text() == "DEBUG = True\n"
def test_apply_patch_edits_preserves_new_file_trailing_blank_lines(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "notes.txt",
"action": "add",
"new_text": "one\n\n",
}
]
)
)
assert "add notes.txt" in result
assert (tmp_path / "notes.txt").read_text() == "one\n\n"
def test_apply_patch_edits_add_to_existing_file(tmp_path):
target = tmp_path / "log.py"
target.write_text("import logging\n\nlogger = logging.getLogger(__name__)\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "log.py",
"action": "add",
"new_text": "def debug(msg):\n logger.debug(msg)",
}
]
)
)
assert "update log.py" in result
assert (
target.read_text()
== "import logging\n\nlogger = logging.getLogger(__name__)\ndef debug(msg):\n logger.debug(msg)\n"
)
def test_apply_patch_edits_delete(tmp_path):
target = tmp_path / "utils.py"
target.write_text("def unused():\n pass\ndef used():\n return 1\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "utils.py",
"action": "delete",
"old_text": "def unused():\n pass\n",
}
]
)
)
assert "update utils.py" in result
assert target.read_text() == "def used():\n return 1\n"
def test_apply_patch_edits_delete_entire_file(tmp_path):
target = tmp_path / "obsolete.txt"
target.write_text("remove me\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "obsolete.txt",
"action": "delete",
"old_text": "remove me\n",
}
]
)
)
assert "delete obsolete.txt" in result
assert not target.exists()
def test_apply_patch_edits_delete_substring_with_surrounding_whitespace(tmp_path):
target = tmp_path / "keep_whitespace.txt"
target.write_text(" token \n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "keep_whitespace.txt",
"action": "delete",
"old_text": "token",
}
]
)
)
assert "update keep_whitespace.txt" in result
assert target.exists()
assert target.read_text() == " \n"
def test_apply_patch_edits_batch_multiple_files(tmp_path):
a = tmp_path / "a.py"
a.write_text("X = 1\n")
b = tmp_path / "b.py"
b.write_text("from a import X\nprint(X)\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "a.py",
"action": "replace",
"old_text": "X = 1",
"new_text": "Y = 1",
},
{
"path": "b.py",
"action": "replace",
"old_text": "from a import X",
"new_text": "from a import Y",
},
]
)
)
assert "update a.py" in result
assert "update b.py" in result
assert a.read_text() == "Y = 1\n"
assert b.read_text() == "from a import Y\nprint(X)\n"
def test_apply_patch_edits_rejects_ambiguous_old_text(tmp_path):
target = tmp_path / "repeated.txt"
target.write_text("target\nmiddle\ntarget\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "repeated.txt",
"action": "replace",
"old_text": "target",
"new_text": "changed",
}
]
)
)
assert "old_text appears multiple times" in result
assert target.read_text() == "target\nmiddle\ntarget\n"
def test_apply_patch_edits_dry_run_validates_without_writing(tmp_path):
target = tmp_path / "dry.txt"
target.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "dry.txt",
"action": "replace",
"old_text": "before",
"new_text": "after",
},
{
"path": "added.txt",
"action": "add",
"new_text": "new",
},
],
dry_run=True,
)
)
assert "Patch dry-run succeeded" in result
assert target.read_text() == "before\n"
assert not (tmp_path / "added.txt").exists()
def test_apply_patch_edits_rejects_absolute_and_parent_paths(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
absolute = asyncio.run(
tool.execute(
edits=[
{
"path": "/tmp/owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
parent = asyncio.run(
tool.execute(
edits=[
{
"path": "../owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_absolute = asyncio.run(
tool.execute(
edits=[
{
"path": r"C:\owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
windows_parent = asyncio.run(
tool.execute(
edits=[
{
"path": r"..\owned.txt",
"action": "add",
"new_text": "nope",
}
]
)
)
assert "must be relative" in absolute
assert "must not contain '..'" in parent
assert "must be relative" in windows_absolute
assert "must not contain '..'" in windows_parent
assert not (tmp_path.parent / "owned.txt").exists()
def test_apply_patch_edits_reports_invalid_edit_shapes(tmp_path):
tool = ApplyPatchTool(workspace=tmp_path)
missing_path = asyncio.run(tool.execute(edits=[{"action": "add", "new_text": "x"}]))
missing_action = asyncio.run(tool.execute(edits=[{"path": "x.txt", "new_text": "x"}]))
non_object = asyncio.run(tool.execute(edits=["not an object"])) # type: ignore[list-item]
assert "path required for edit" in missing_path
assert "action required for edit: x.txt" in missing_action
assert "each edit must be an object" in non_object
def test_apply_patch_edits_rolls_back_when_late_operation_fails(tmp_path):
first = tmp_path / "first.txt"
first.write_text("before\n")
tool = ApplyPatchTool(workspace=tmp_path)
result = asyncio.run(
tool.execute(
edits=[
{
"path": "first.txt",
"action": "replace",
"old_text": "before",
"new_text": "after",
},
{
"path": "missing.txt",
"action": "delete",
"old_text": "remove me",
},
]
)
)
assert "file to update does not exist: missing.txt" in result
assert first.read_text() == "before\n"
+7 -12
View File
@@ -1,5 +1,5 @@
"""Tests for EditFileTool enhancements: read-before-edit tracking, path suggestions,
notebook JSON editing, and create-file semantics."""
.ipynb detection, and create-file semantics."""
import pytest
@@ -108,27 +108,22 @@ class TestEditCreateFile:
# ---------------------------------------------------------------------------
# .ipynb editing
# .ipynb detection
# ---------------------------------------------------------------------------
class TestEditIpynbFiles:
"""edit_file edits notebooks as normal JSON files."""
class TestEditIpynbDetection:
"""edit_file should refuse .ipynb and suggest notebook_edit."""
@pytest.fixture()
def tool(self, tmp_path):
return EditFileTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_ipynb_can_be_edited_as_json(self, tool, tmp_path):
async def test_ipynb_rejected_with_suggestion(self, tool, tmp_path):
f = tmp_path / "analysis.ipynb"
f.write_text('{"cells": []}', encoding="utf-8")
result = await tool.execute(
path=str(f),
old_text='"cells": []',
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
)
assert "Successfully edited" in result
assert '"source": "hi"' in f.read_text(encoding="utf-8")
result = await tool.execute(path=str(f), old_text="x", new_text="y")
assert "notebook" in result.lower()
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -162,7 +162,7 @@ class TestPathAppendPlatform:
captured_cmd = None
captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
async def capture_spawn(cmd, cwd, env):
nonlocal captured_cmd
captured_cmd = cmd
captured_env.update(env)
@@ -190,7 +190,7 @@ class TestPathAppendPlatform:
captured_env = {}
async def capture_spawn(cmd, cwd, env, shell_program=None, login=True):
async def capture_spawn(cmd, cwd, env):
captured_env.update(env)
return mock_proc
-361
View File
@@ -1,361 +0,0 @@
from __future__ import annotations
import asyncio
import re
import shlex
import subprocess
import sys
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.exec_session import ExecSessionManager, ListExecSessionsTool, WriteStdinTool
def _python_command(code: str) -> str:
if sys.platform == "win32":
return f"{subprocess.list2cmdline([sys.executable])} -u -c {subprocess.list2cmdline([code])}"
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
def _session_id(output: str) -> str:
match = re.search(r"session_id:\s*([0-9a-f]+)", output)
assert match, output
return match.group(1)
def test_exec_keeps_one_shot_behavior_without_yield_time_ms(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo hello")
result = asyncio.run(run())
assert "hello" in result
assert "Exit code: 0" in result
assert "session_id:" not in result
def test_exec_accepts_command_aliases(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir="/")
return await tool.execute(
cmd=_python_command("import os; print(os.getcwd())"),
workdir=str(tmp_path),
)
result = asyncio.run(run())
assert str(tmp_path) in result
assert "Exit code: 0" in result
def test_exec_returns_completed_session_output_when_yield_time_ms_is_used(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
result = await tool.execute(command="echo hello", yield_time_ms=1000)
if "session_id:" in result:
sid = _session_id(result)
result += "\n" + await stdin_tool.execute(
session_id=sid,
chars="",
yield_time_ms=1000,
)
return result
result = asyncio.run(run())
assert "hello" in result
assert "Exit code: 0" in result
assert "session_id:" not in result
def test_exec_session_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
manager = ExecSessionManager()
tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
command = _python_command("print('A' * 2000)")
return await tool.execute(
command=command,
yield_time_ms=1000,
max_output_tokens=1000,
)
result = asyncio.run(run())
assert "chars truncated" in result
assert "Exit code: 0" in result
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
command = _python_command("print('A' * 2000)")
return await tool.execute(command=command, max_output_tokens=1000)
result = asyncio.run(run())
assert "chars truncated" in result
assert "Exit code: 0" in result
def test_exec_accepts_supported_shell_parameter(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo shell-ok", shell="sh", login=False)
if sys.platform == "win32":
return
result = asyncio.run(run())
assert "shell-ok" in result
assert "Exit code: 0" in result
def test_exec_rejects_unsupported_shell(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
return await tool.execute(command="echo no", shell="python")
if sys.platform == "win32":
return
result = asyncio.run(run())
assert "unsupported shell" in result
def test_exec_can_continue_with_stdin(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import sys; print('ready', flush=True); "
"line=sys.stdin.readline(); print('got:' + line.strip(), flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(session_id=sid, chars="ping\n", yield_time_ms=1000)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "Process running" in initial
assert "Elapsed:" in initial
assert "got:ping" in result
assert "Exit code: 0" in result
assert "Elapsed:" in result
def test_write_stdin_can_close_stdin(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import sys; print('ready', flush=True); "
"data=sys.stdin.read(); print('got:' + data, flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(
session_id=sid,
chars="payload",
close_stdin=True,
yield_time_ms=1000,
)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "got:payload" in result
assert "Stdin closed." in result
assert "Exit code: 0" in result
def test_write_stdin_can_terminate_session(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); time.sleep(30)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
result = await stdin_tool.execute(
session_id=sid,
terminate=True,
yield_time_ms=0,
)
return initial, result
initial, result = asyncio.run(run())
assert "ready" in initial
assert "Session terminated." in result
assert "Exit code:" in result
def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('A' * 2000, flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=0)
sid = _session_id(initial)
poll = await stdin_tool.execute(
session_id=sid,
yield_time_ms=500,
max_output_tokens=1000,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, poll, cleanup
initial, poll, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "chars truncated" in poll
assert "Session terminated." in cleanup
def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
async def run() -> tuple[str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); "
"time.sleep(1.0); print('done', flush=True)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=300)
sid = _session_id(initial)
await asyncio.sleep(1.2)
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
return initial, final
initial, final = asyncio.run(run())
assert "ready" in initial
assert "done" in final
assert "Exit code: 0" in final
def test_write_stdin_can_wait_for_expected_output(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('booting', flush=True); "
"time.sleep(0.4); print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial)
waited = await stdin_tool.execute(
session_id=sid,
wait_for="ready",
wait_timeout_ms=3000,
yield_time_ms=0,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, waited, cleanup
initial, waited, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "booting" in initial + waited
assert "ready" in waited
assert "Wait target not observed" not in waited
assert "Session terminated." in cleanup
def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('booting', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial)
waited = await stdin_tool.execute(
session_id=sid,
wait_for="never-ready",
wait_timeout_ms=200,
yield_time_ms=0,
)
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return initial, waited, cleanup
initial, waited, cleanup = asyncio.run(run())
assert "Process running" in initial
assert "booting" in initial + waited
assert "Process running" in waited
assert "Wait target not observed: 'never-ready'" in waited
assert "Session terminated." in cleanup
def test_exec_session_mode_reuses_exec_safety_guard(tmp_path):
manager = ExecSessionManager()
tool = ExecTool(
working_dir=str(tmp_path),
deny_patterns=[r"echo\s+blocked"],
session_manager=manager,
)
result = asyncio.run(tool.execute(command="echo blocked", yield_time_ms=0))
assert "blocked by deny pattern" in result
def test_write_stdin_reports_missing_session(tmp_path):
manager = ExecSessionManager()
tool = WriteStdinTool(manager=manager)
result = asyncio.run(tool.execute(session_id="missing", chars=""))
assert "exec session not found" in result
def test_list_exec_sessions_reports_running_commands(tmp_path):
async def run() -> tuple[str, str, str]:
manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
list_tool = ListExecSessionsTool(manager=manager)
stdin_tool = WriteStdinTool(manager=manager)
command = _python_command(
"import time; print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial)
listing = await list_tool.execute()
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
return sid, listing, cleanup
sid, listing, cleanup = asyncio.run(run())
assert sid in listing
assert "running" in listing
assert "elapsed=" in listing
assert "remaining=" in listing
assert str(tmp_path) in listing
assert "Session terminated." in cleanup
def test_list_exec_sessions_reports_empty_state():
result = asyncio.run(ListExecSessionsTool(manager=ExecSessionManager()).execute())
assert result == "No active exec sessions."
@@ -1,216 +0,0 @@
from __future__ import annotations
import asyncio
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
def test_read_file_force_bypasses_dedup(tmp_path):
target = tmp_path / "data.txt"
target.write_text("alpha\n")
tool = ReadFileTool(workspace=tmp_path)
first = asyncio.run(tool.execute(path=str(target)))
second = asyncio.run(tool.execute(path=str(target)))
forced = asyncio.run(tool.execute(path=str(target), force=True))
assert "alpha" in first
assert "unchanged" in second.lower()
assert "alpha" in forced
assert "unchanged" not in forced.lower()
def test_edit_file_can_select_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("one\nsame\ntwo\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=2,
))
assert "Successfully edited" in result
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
def test_edit_file_expected_replacements_guards_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
replace_all=True,
expected_replacements=1,
))
assert "expected 1 replacements but would make 2" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_expected_replacements_allows_replace_all_when_count_matches(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
replace_all=True,
expected_replacements=2,
))
assert "Successfully edited" in result
assert target.read_text() == "changed\nchanged\n"
def test_edit_file_can_select_nearest_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("one\nsame\ntwo\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=4,
))
assert "Successfully edited" in result
assert target.read_text() == "one\nsame\ntwo\nchanged\n"
def test_edit_file_can_edit_ipynb_as_json(tmp_path):
target = tmp_path / "analysis.ipynb"
target.write_text('{"cells": []}')
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text='"cells": []',
new_text='"cells": [{"cell_type": "markdown", "source": "hi"}]',
))
assert "Successfully edited" in result
assert '"source": "hi"' in target.read_text()
def test_edit_file_multiple_match_hint_mentions_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
))
assert "old_text appears 2 times" in result
assert "occurrence" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_ambiguous_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nmiddle\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=2,
))
assert "line_hint 2 is ambiguous" in result
assert target.read_text() == "same\nmiddle\nsame\n"
def test_edit_file_rejects_occurrence_with_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=1,
replace_all=True,
))
assert "occurrence cannot be used with replace_all" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_line_hint_with_replace_all(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=1,
replace_all=True,
))
assert "line_hint cannot be used with replace_all" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_line_hint_with_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\nsame\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=1,
line_hint=1,
))
assert "line_hint cannot be used with occurrence" in result
assert target.read_text() == "same\nsame\n"
def test_edit_file_rejects_zero_occurrence(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
occurrence=0,
))
assert "occurrence must be >= 1" in result
assert target.read_text() == "same\n"
def test_edit_file_rejects_zero_line_hint(tmp_path):
target = tmp_path / "duplicate.txt"
target.write_text("same\n")
tool = EditFileTool(workspace=tmp_path)
result = asyncio.run(tool.execute(
path=str(target),
old_text="same",
new_text="changed",
line_hint=0,
))
assert "line_hint must be >= 1" in result
assert target.read_text() == "same\n"
-33
View File
@@ -138,39 +138,6 @@ async def test_generate_image_tool_reports_missing_aihubmix_key(tmp_path: Path)
assert result.startswith("Error: AIHubMix API key is not configured")
@pytest.mark.asyncio
async def test_generate_image_tool_allows_ollama_without_api_key(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
set_config_path(tmp_path / "config.json")
FakeImageClient.instances = []
monkeypatch.setattr(
"nanobot.agent.tools.image_generation.get_image_gen_provider",
lambda name: FakeImageClient if name == "ollama" else None,
)
tool = ImageGenerationTool(
workspace=tmp_path,
config=ImageGenerationToolConfig(
enabled=True,
provider="ollama",
model="x/z-image-turbo",
),
provider_configs={"ollama": ProviderConfig(api_base="http://localhost:11434/v1")},
)
result = await tool.execute(prompt="draw a cat")
payload = json.loads(result)
assert len(payload["artifacts"]) == 1
fake = FakeImageClient.instances[0]
assert fake.kwargs["api_key"] is None
assert fake.kwargs["api_base"] == "http://localhost:11434/v1"
assert fake.calls[0]["aspect_ratio"] == "1:1"
assert fake.calls[0]["image_size"] == "1K"
@pytest.mark.asyncio
async def test_generate_image_tool_rejects_reference_outside_workspace(tmp_path: Path) -> None:
set_config_path(tmp_path / "config.json")
+147
View File
@@ -0,0 +1,147 @@
"""Tests for NotebookEditTool — Jupyter .ipynb editing."""
import json
import pytest
from nanobot.agent.tools.notebook import NotebookEditTool
def _make_notebook(cells: list[dict] | None = None, nbformat: int = 4, nbformat_minor: int = 5) -> dict:
"""Build a minimal valid .ipynb structure."""
return {
"nbformat": nbformat,
"nbformat_minor": nbformat_minor,
"metadata": {"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"}},
"cells": cells or [],
}
def _code_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "code", "source": source, "metadata": {}, "outputs": [], "execution_count": None}
if cell_id:
cell["id"] = cell_id
return cell
def _md_cell(source: str, cell_id: str | None = None) -> dict:
cell = {"cell_type": "markdown", "source": source, "metadata": {}}
if cell_id:
cell["id"] = cell_id
return cell
def _write_nb(tmp_path, name: str, nb: dict) -> str:
p = tmp_path / name
p.write_text(json.dumps(nb), encoding="utf-8")
return str(p)
class TestNotebookEdit:
@pytest.fixture()
def tool(self, tmp_path):
return NotebookEditTool(workspace=tmp_path)
@pytest.mark.asyncio
async def test_replace_cell_content(self, tool, tmp_path):
nb = _make_notebook([_code_cell("print('hello')"), _code_cell("x = 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="print('world')")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][0]["source"] == "print('world')"
assert saved["cells"][1]["source"] == "x = 1"
@pytest.mark.asyncio
async def test_insert_cell_after_target(self, tool, tmp_path):
nb = _make_notebook([_code_cell("cell 0"), _code_cell("cell 1")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="inserted", edit_mode="insert")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 3
assert saved["cells"][0]["source"] == "cell 0"
assert saved["cells"][1]["source"] == "inserted"
assert saved["cells"][2]["source"] == "cell 1"
@pytest.mark.asyncio
async def test_delete_cell(self, tool, tmp_path):
nb = _make_notebook([_code_cell("A"), _code_cell("B"), _code_cell("C")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=1, edit_mode="delete")
assert "Successfully" in result
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert len(saved["cells"]) == 2
assert saved["cells"][0]["source"] == "A"
assert saved["cells"][1]["source"] == "C"
@pytest.mark.asyncio
async def test_create_new_notebook_from_scratch(self, tool, tmp_path):
path = str(tmp_path / "new.ipynb")
result = await tool.execute(path=path, cell_index=0, new_source="# Hello", edit_mode="insert", cell_type="markdown")
assert "Successfully" in result or "created" in result.lower()
saved = json.loads((tmp_path / "new.ipynb").read_text())
assert saved["nbformat"] == 4
assert len(saved["cells"]) == 1
assert saved["cells"][0]["cell_type"] == "markdown"
assert saved["cells"][0]["source"] == "# Hello"
@pytest.mark.asyncio
async def test_invalid_cell_index_error(self, tool, tmp_path):
nb = _make_notebook([_code_cell("only cell")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=5, new_source="x")
assert "Error" in result
@pytest.mark.asyncio
async def test_non_ipynb_rejected(self, tool, tmp_path):
f = tmp_path / "script.py"
f.write_text("pass")
result = await tool.execute(path=str(f), cell_index=0, new_source="x")
assert "Error" in result
assert ".ipynb" in result
@pytest.mark.asyncio
async def test_preserves_metadata_and_outputs(self, tool, tmp_path):
cell = _code_cell("old")
cell["outputs"] = [{"output_type": "stream", "text": "hello\n"}]
cell["execution_count"] = 42
nb = _make_notebook([cell])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="new")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["metadata"]["kernelspec"]["language"] == "python"
@pytest.mark.asyncio
async def test_nbformat_45_generates_cell_id(self, tool, tmp_path):
nb = _make_notebook([], nbformat_minor=5)
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="x = 1", edit_mode="insert")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert "id" in saved["cells"][0]
assert len(saved["cells"][0]["id"]) > 0
@pytest.mark.asyncio
async def test_insert_with_cell_type_markdown(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
await tool.execute(path=path, cell_index=0, new_source="# Title", edit_mode="insert", cell_type="markdown")
saved = json.loads((tmp_path / "test.ipynb").read_text())
assert saved["cells"][1]["cell_type"] == "markdown"
@pytest.mark.asyncio
async def test_invalid_edit_mode_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", edit_mode="replcae")
assert "Error" in result
assert "edit_mode" in result
@pytest.mark.asyncio
async def test_invalid_cell_type_rejected(self, tool, tmp_path):
nb = _make_notebook([_code_cell("code")])
path = _write_nb(tmp_path, "test.ipynb", nb)
result = await tool.execute(path=path, cell_index=0, new_source="x", cell_type="raw")
assert "Error" in result
assert "cell_type" in result
+1 -65
View File
@@ -12,7 +12,7 @@ import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager, SubagentStatus
from nanobot.agent.tools.search import FindFilesTool, GrepTool
from nanobot.agent.tools.search import GrepTool
from nanobot.agent.tools.web import WebSearchTool
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import WebSearchConfig
@@ -33,68 +33,6 @@ async def test_web_search_tool_refreshes_dynamic_config_loader(monkeypatch) -> N
assert await tool.execute("nanobot") == "duckduckgo:nanobot:3"
@pytest.mark.asyncio
async def test_find_files_filters_by_query_glob_and_type(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "settings_view.tsx").write_text("export {}\n", encoding="utf-8")
(tmp_path / "src" / "settings_api.py").write_text("pass\n", encoding="utf-8")
(tmp_path / "README.md").write_text("settings\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(
path=".",
query="settings",
glob="src/**",
type="ts",
)
assert result.splitlines() == ["src/settings_view.tsx"]
@pytest.mark.asyncio
async def test_find_files_can_include_directories(tmp_path: Path) -> None:
(tmp_path / "src" / "settings").mkdir(parents=True)
(tmp_path / "src" / "settings" / "index.ts").write_text("export {}\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(path="src", query="settings", include_dirs=True)
assert "src/settings/" in result.splitlines()
assert "src/settings/index.ts" in result.splitlines()
@pytest.mark.asyncio
async def test_find_files_supports_modified_sort_and_pagination(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
for idx, name in enumerate(("a.py", "b.py", "c.py"), start=1):
file_path = tmp_path / "src" / name
file_path.write_text("pass\n", encoding="utf-8")
os.utime(file_path, (idx, idx))
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(
path="src",
type="py",
sort="modified",
head_limit=1,
offset=1,
)
assert result.splitlines()[0] == "src/b.py"
assert "pagination: limit=1, offset=1" in result
@pytest.mark.asyncio
async def test_find_files_rejects_paths_outside_workspace(tmp_path: Path) -> None:
outside = tmp_path.parent / "outside-find-files.txt"
outside.write_text("secret\n", encoding="utf-8")
tool = FindFilesTool(workspace=tmp_path, allowed_dir=tmp_path)
result = await tool.execute(path=str(outside))
assert result.startswith("Error:")
@pytest.mark.asyncio
async def test_grep_respects_glob_filter_and_context(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
@@ -311,7 +249,6 @@ def test_agent_loop_registers_grep(tmp_path: Path) -> None:
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
assert "find_files" in loop.tools.tool_names
assert "grep" in loop.tools.tool_names
@@ -343,7 +280,6 @@ async def test_subagent_registers_grep(tmp_path: Path) -> None:
status = SubagentStatus(task_id="sub-1", label="label", task_description="search task", started_at=time.monotonic())
await mgr._run_subagent("sub-1", "search task", "label", {"channel": "cli", "chat_id": "direct"}, status)
assert "find_files" in captured["tool_names"]
assert "grep" in captured["tool_names"]
-46
View File
@@ -1,46 +0,0 @@
from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.exec_session import ListExecSessionsTool, WriteStdinTool
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools.search import FindFilesTool, GrepTool
from nanobot.agent.tools.shell import ExecTool
def test_coding_tool_descriptions_steer_editing_priority() -> None:
apply_patch = ApplyPatchTool().description.lower()
edit_file = EditFileTool().description.lower()
write_file = WriteFileTool().description.lower()
assert "default tool for code edits" in apply_patch
assert "multi-file" in apply_patch
assert "dry_run=true" in apply_patch
assert "edit_file only for small exact replacements" in apply_patch
assert "small, exact replacement" in edit_file
assert "copied from read_file" in edit_file
assert "prefer apply_patch" in edit_file
assert "replace an entire file" in write_file
assert "prefer apply_patch" in write_file
def test_coding_tool_descriptions_steer_discovery_and_shell_usage() -> None:
read_file = ReadFileTool().description.lower()
find_files = FindFilesTool().description.lower()
grep = GrepTool().description.lower()
exec_tool = ExecTool().description.lower()
write_stdin = WriteStdinTool().description.lower()
list_sessions = ListExecSessionsTool().description.lower()
assert "find_files/list_dir first" in read_file
assert "before editing" in read_file
assert "prefer it over shell find/ls" in find_files
assert "prefer this over shell grep" in grep
assert "tests, builds" in exec_tool
assert "prefer read_file/find_files/grep" in exec_tool
assert "apply_patch/write_file/edit_file" in exec_tool
assert "yield_time_ms" in exec_tool
assert "do not use this to start new commands" in write_stdin
assert "wait_for" in write_stdin
assert "recover a session_id" in list_sessions
+1 -6
View File
@@ -89,12 +89,9 @@ def test_discover_finds_concrete_tools():
loader = ToolLoader()
discovered = loader.discover()
class_names = {cls.__name__ for cls in discovered}
assert "ApplyPatchTool" in class_names
assert "ExecTool" in class_names
assert "CliAppsTool" in class_names
assert "MessageTool" in class_names
assert "SpawnTool" in class_names
assert "WriteStdinTool" in class_names
def test_discover_excludes_abstract_and_mcp():
@@ -367,7 +364,6 @@ def test_config_defaults():
assert config.tools.my.enable is True
assert config.tools.my.allow_set is False
assert config.tools.image_generation.enabled is False
assert config.tools.cli_apps.enable is True
assert config.tools.restrict_to_workspace is False
@@ -410,8 +406,7 @@ def test_loader_registers_same_tools_as_old_hardcoded():
expected = {
"read_file", "write_file", "edit_file", "list_dir",
"find_files", "grep", "exec", "write_stdin", "list_exec_sessions",
"web_search", "web_fetch",
"grep", "notebook_edit", "exec", "web_search", "web_fetch",
"message", "spawn", "cron",
}
actual = set(registered)
-36
View File
@@ -3,8 +3,6 @@ import subprocess
import sys
from typing import Any
import pytest
from nanobot.agent.tools import (
ArraySchema,
IntegerSchema,
@@ -17,7 +15,6 @@ from nanobot.agent.tools import (
from nanobot.agent.tools.base import Tool
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.shell import ExecTool
from nanobot.security.network import configure_ssrf_whitelist
class SampleTool(Tool):
@@ -221,39 +218,6 @@ def test_exec_extract_absolute_paths_ignores_relative_posix_segments() -> None:
assert "/bin/python" not in paths
def test_exec_extract_absolute_paths_ignores_urls() -> None:
cmd = 'curl -s -o /dev/null -w "%{http_code}" https://www.google.com'
paths = ExecTool._extract_absolute_paths(cmd)
assert paths == ["/dev/null"]
@pytest.mark.parametrize(
"command",
[
'curl -s -o /dev/null -w "%{http_code}" https://www.google.com',
'wget -q -O - http://example.com 2>&1 | head -c 100',
'python3 -c "import urllib.request; print(urllib.request.urlopen(\'http://example.com\').read()[:100])"',
],
)
def test_exec_guard_allows_public_urls(tmp_path, command: str) -> None:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command(command, str(tmp_path))
assert error is None
def test_exec_guard_allows_whitelisted_internal_urls(tmp_path) -> None:
configure_ssrf_whitelist(["10.10.10.0/24"])
try:
tool = ExecTool(restrict_to_workspace=True)
error = tool._guard_command(
'curl -s -H "Authorization: Bearer ..." http://10.10.10.3:8123/api/',
str(tmp_path),
)
assert error is None
finally:
configure_ssrf_whitelist([])
def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None:
cmd = "cat /tmp/data.txt > /tmp/out.txt"
paths = ExecTool._extract_absolute_paths(cmd)
+12 -124
View File
@@ -6,15 +6,11 @@ import json
import socket
from unittest.mock import patch
import httpx
import pytest
from nanobot.agent.tools import web as web_module
from nanobot.agent.tools.web import WebFetchTool
from nanobot.config.schema import WebFetchConfig
_REAL_GETADDRINFO = socket.getaddrinfo
def _fake_resolve_private(hostname, port, family=0, type_=0):
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
@@ -58,7 +54,6 @@ async def test_web_fetch_result_contains_untrusted_flag():
url = "https://example.com/page"
text = fake_html
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self): pass
def json(self): return {}
@@ -86,7 +81,6 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
raise AssertionError("Jina Reader should be skipped when disabled")
class FakeStreamResponse:
status_code = 200
headers = {"content-type": "text/html"}
url = "https://example.com/page"
@@ -96,15 +90,11 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
async def __aexit__(self, exc_type, exc, tb):
return False
async def aread(self):
raise AssertionError("non-image prefetch body should not be read")
class FakeResponse:
status_code = 200
url = "https://example.com/page"
text = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self):
return None
@@ -119,11 +109,11 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None, **kwargs):
def stream(self, method, url, headers=None):
seen_headers.append(headers or {})
return FakeStreamResponse()
async def get(self, url, headers=None, **kwargs):
async def get(self, url, headers=None):
seen_headers.append(headers or {})
return FakeResponse()
@@ -142,14 +132,13 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_readability_request(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
requested: list[str] = []
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
tool = WebFetchTool()
class FakeStreamResponse:
status_code = 200
headers = {"content-type": "text/html"}
url = "https://attacker.example/start"
headers = {"content-type": "image/png"}
url = "http://127.0.0.1/secret.png"
content = b"\x89PNG\r\n\x1a\n"
async def __aenter__(self):
return self
@@ -158,14 +147,9 @@ async def test_web_fetch_blocks_private_redirect_before_readability_request(monk
return False
async def aread(self):
raise AssertionError("non-image prefetch body should not be read")
return self.content
class FakeRedirectResponse:
status_code = 302
headers = {"location": "http://127.0.0.1:8765/metadata"}
url = "https://attacker.example/start"
async def aclose(self):
def raise_for_status(self):
return None
class FakeClient:
@@ -178,110 +162,14 @@ async def test_web_fetch_blocks_private_redirect_before_readability_request(monk
async def __aexit__(self, exc_type, exc, tb):
return False
def stream(self, method, url, headers=None, **kwargs):
def stream(self, method, url, headers=None):
return FakeStreamResponse()
async def get(self, url, headers=None, **kwargs):
requested.append(url)
if url == "http://127.0.0.1:8765/metadata":
raise AssertionError("private redirect target should not be requested")
return FakeRedirectResponse()
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
result = await tool.execute(url="https://attacker.example/start")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
assert requested == ["https://attacker.example/start"]
@pytest.mark.asyncio
async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
def handler(request: httpx.Request) -> httpx.Response:
if str(request.url) == "https://example.com/image.png":
return httpx.Response(
302,
headers={"Location": "http://127.0.0.1/secret.png"},
request=request,
)
if str(request.url) == "http://127.0.0.1/secret.png":
return httpx.Response(
200,
headers={"content-type": "image/png"},
content=b"\x89PNG\r\n\x1a\n",
request=request,
)
return httpx.Response(404, request=request)
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
class TransportAsyncClient(real_async_client):
def __init__(self, *args, **kwargs):
kwargs.pop("proxy", None)
super().__init__(*args, transport=transport, **kwargs)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "example.com":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool.execute(url="https://example.com/image.png")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
@pytest.mark.asyncio
async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
tool = WebFetchTool(config=WebFetchConfig(use_jina_reader=False))
requested: list[str] = []
def handler(request: httpx.Request) -> httpx.Response:
requested.append(str(request.url))
if str(request.url) == "https://attacker.example/start":
return httpx.Response(
302,
headers={"Location": "http://127.0.0.1:8765/metadata"},
request=request,
)
if str(request.url) == "http://127.0.0.1:8765/metadata":
return httpx.Response(200, content=b"internal secret", request=request)
return httpx.Response(404, request=request)
transport = httpx.MockTransport(handler)
real_async_client = httpx.AsyncClient
class TransportAsyncClient(real_async_client):
def __init__(self, *args, **kwargs):
kwargs["transport"] = transport
super().__init__(*args, **kwargs)
monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example":
return _fake_resolve_public(hostname, port, family, type_)
return _REAL_GETADDRINFO(hostname, port, family, type_)
with patch("nanobot.security.network.socket.getaddrinfo", resolve_public_start_only):
result = await tool.execute(url="https://attacker.example/start")
data = json.loads(result)
assert "error" in data
assert "redirect blocked" in data["error"].lower()
assert requested == ["https://attacker.example/start"]
+1 -148
View File
@@ -5,13 +5,12 @@ from pathlib import Path
from types import SimpleNamespace
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
build_file_edit_start_event,
line_diff_stats,
prepare_file_edit_tracker,
prepare_file_edit_trackers,
read_file_snapshot,
StreamingFileEditTracker,
)
@@ -82,63 +81,6 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert (event["added"], event["deleted"]) == (0, 0)
def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
existing = tmp_path / "src" / "existing.py"
existing.write_text("old\nkeep\n", encoding="utf-8")
delete_me = tmp_path / "src" / "delete_me.py"
delete_me.write_text("gone\n", encoding="utf-8")
edits = [
{"path": "src/new.py", "action": "add", "new_text": "fresh"},
{"path": "src/existing.py", "action": "replace", "old_text": "old", "new_text": "new"},
{"path": "src/delete_me.py", "action": "delete", "old_text": "gone\n"},
]
trackers = prepare_file_edit_trackers(
call_id="call-patch",
tool_name="apply_patch",
tool=None,
workspace=tmp_path,
params={"edits": edits},
)
assert [tracker.display_path for tracker in trackers] == [
"src/new.py",
"src/existing.py",
"src/delete_me.py",
]
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
existing.write_text("new\nkeep\n", encoding="utf-8")
delete_me.unlink()
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers]
by_path = {event["path"]: event for event in events}
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/delete_me.py"]["added"], by_path["src/delete_me.py"]["deleted"]) == (0, 1)
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
(tmp_path / "file.txt").write_text("old\n", encoding="utf-8")
trackers = prepare_file_edit_trackers(
call_id="call-patch",
tool_name="apply_patch",
tool=None,
workspace=tmp_path,
params={
"dry_run": True,
"edits": [
{"path": "file.txt", "action": "replace", "old_text": "old", "new_text": "new"}
],
},
)
assert trackers == []
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None:
target = tmp_path / "large.txt"
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
@@ -198,58 +140,6 @@ def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) ->
assert events[-1]["deleted"] == 0
def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "existing.py").write_text("old\nkeep\n", encoding="utf-8")
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}'
',{"path":"src/new.py","action":"add","new_text":"fresh"}]}'
),
})
asyncio.run(run())
by_path = {event["path"]: event for event in events}
assert by_path["src/existing.py"]["tool"] == "apply_patch"
assert by_path["src/existing.py"]["status"] == "editing"
assert by_path["src/existing.py"]["approximate"] is True
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}'
),
})
asyncio.run(run())
assert events == []
def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
events: list[dict] = []
@@ -418,43 +308,6 @@ def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Pat
asyncio.run(run())
def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"a.md","content":"one\\n"}',
})
await tracker.update({
"index": 1,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"b.md","content":"two\\n"}',
})
final_a = SimpleNamespace(
id="call_dup",
name="write_file",
arguments={"path": "a.md", "content": "one\n"},
)
final_b = SimpleNamespace(
id="call_unique",
name="write_file",
arguments={"path": "b.md", "content": "two\n"},
)
tracker.apply_final_call_ids([final_a, final_b])
assert final_a.id == "call_dup"
assert final_b.id == "call_unique"
asyncio.run(run())
def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
target = tmp_path / "small.py"
target.write_text("old\n", encoding="utf-8")
-68
View File
@@ -42,30 +42,6 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
def test_replay_augments_assistant_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-img", "text": "draw"},
{"event": "delta", "chat_id": "t-img", "text": "![Diagram](diagram.png)"},
{"event": "stream_end", "chat_id": "t-img"},
],
augment_assistant_text=lambda text: text.replace("diagram.png", "/api/media/sig/payload"),
)
assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)"
def test_replay_uses_stream_end_final_text() -> None:
msgs = replay_transcript_to_ui_messages(
[
{"event": "user", "chat_id": "t-img", "text": "draw"},
{"event": "stream_end", "chat_id": "t-img", "text": "![Diagram](/api/media/sig/payload)"},
],
)
assert msgs[1]["content"] == "![Diagram](/api/media/sig/payload)"
def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file"
@@ -167,50 +143,6 @@ def test_replay_tool_events_dedupes_finish_after_start() -> None:
'exec({"cmd": "ls"})',
'read_file({"path": "notes.md"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "end"
assert msgs[0]["toolEvents"][0]["call_id"] == "call-exec"
def test_replay_tool_events_keeps_phase_update_when_trace_is_deduped() -> None:
args = {"name": "github", "args": ["repo", "view"], "json": "true"}
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
},
],
},
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "error",
"call_id": "call-cli",
"name": "run_cli_app",
"arguments": args,
"error": "Error: CLI app 'github' not found",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["traces"] == [
'run_cli_app({"name": "github", "args": ["repo", "view"], "json": "true"})',
]
assert msgs[0]["toolEvents"][0]["phase"] == "error"
assert msgs[0]["toolEvents"][0]["error"] == "Error: CLI app 'github' not found"
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
+11 -31
View File
@@ -43,7 +43,6 @@ const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.v1";
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56;
const TOKEN_REFRESH_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings";
@@ -412,10 +411,6 @@ function Shell({
setDesktopSidebarOpen(false);
}, []);
const openDesktopSidebar = useCallback(() => {
setDesktopSidebarOpen(true);
}, []);
const closeMobileSidebar = useCallback(() => {
setMobileSidebarOpen(false);
}, []);
@@ -565,21 +560,6 @@ function Shell({
setSessionSearchOpen(true);
}, []);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.defaultPrevented) return;
const plainCommandK =
(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey;
if (!plainCommandK) return;
if (event.key.toLowerCase() !== "k") return;
event.preventDefault();
onOpenSessionSearch();
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [onOpenSessionSearch]);
const onSelectSearchResult = useCallback(
(key: string) => {
setSessionSearchOpen(false);
@@ -752,19 +732,17 @@ function Shell({
"relative z-20 hidden shrink-0 overflow-hidden lg:block",
"transition-[width] duration-300 ease-out",
)}
style={{
width: desktopSidebarOpen ? SIDEBAR_WIDTH : SIDEBAR_RAIL_WIDTH,
}}
style={{ width: desktopSidebarOpen ? SIDEBAR_WIDTH : 0 }}
>
<div
className="absolute inset-y-0 left-0 h-full w-full overflow-hidden bg-sidebar shadow-inner-right"
className={cn(
"absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
style={{ width: SIDEBAR_WIDTH }}
>
<Sidebar
{...sidebarProps}
collapsed={!desktopSidebarOpen}
onCollapse={closeDesktopSidebar}
onExpand={openDesktopSidebar}
/>
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div>
</aside>
) : null}
@@ -791,6 +769,7 @@ function Shell({
</Sheet>
) : null}
{showMainSidebar ? (
<SessionSearchDialog
open={sessionSearchOpen}
onOpenChange={setSessionSearchOpen}
@@ -800,6 +779,7 @@ function Shell({
titleOverrides={sidebarState.title_overrides}
onSelect={onSelectSearchResult}
/>
) : null}
<main className="relative flex h-full min-w-0 flex-1 flex-col">
<div
@@ -817,7 +797,7 @@ function Shell({
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
hideSidebarToggleOnDesktop
hideSidebarToggleOnDesktop={desktopSidebarOpen}
/>
</div>
{view === "settings" && (
+18 -112
View File
@@ -1,9 +1,3 @@
import {
memo,
useEffect,
useMemo,
useState,
} from "react";
import {
Archive,
ArchiveRestore,
@@ -25,9 +19,6 @@ import { deriveTitle, relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
@@ -51,7 +42,7 @@ interface ChatListProps {
emptyLabel?: string;
}
export const ChatList = memo(function ChatList({
export function ChatList({
sessions,
activeKey,
onSelect,
@@ -74,52 +65,6 @@ export const ChatList = memo(function ChatList({
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
() => groupSessions(sessions, labels, {
pinnedKeys,
archivedKeys,
titleOverrides,
showArchived,
sort,
}),
[
archivedKeys,
labels,
pinnedKeys,
sessions,
showArchived,
sort,
titleOverrides,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey),
[activeKey, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce((total, group) => total + group.sessions.length, 0),
[groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -136,6 +81,21 @@ export const ChatList = memo(function ChatList({
);
}
const groups = groupSessions(sessions, {
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
fallbackTitle: t("chat.newChat"),
}, {
pinnedKeys,
archivedKeys,
titleOverrides,
showArchived,
sort,
});
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
@@ -145,7 +105,7 @@ export const ChatList = memo(function ChatList({
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group) => (
{groups.map((group) => (
<section key={group.label} aria-label={group.label}>
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{group.label}
@@ -268,25 +228,10 @@ export const ChatList = memo(function ChatList({
</ul>
</section>
))}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
type="button"
onClick={() =>
setVisibleLimit((limit) =>
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
</div>
) : null}
</div>
</div>
);
});
}
function SessionActivityIndicator({
state,
@@ -421,45 +366,6 @@ function groupSessions(
return groups;
}
function limitGroups(
groups: Array<{ label: string; sessions: ChatSummary[] }>,
limit: number,
activeKey: string | null,
): Array<{ label: string; sessions: ChatSummary[] }> {
let remaining = Math.max(0, limit);
let activeVisible = !activeKey;
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
for (const group of groups) {
const visible = remaining > 0
? group.sessions.slice(0, remaining)
: [];
remaining -= visible.length;
if (activeKey && visible.some((session) => session.key === activeKey)) {
activeVisible = true;
}
if (visible.length > 0) {
out.push({ label: group.label, sessions: visible });
}
}
if (activeVisible || !activeKey) return out;
for (const group of groups) {
const active = group.sessions.find((session) => session.key === activeKey);
if (!active) continue;
const existing = out.find((item) => item.label === group.label);
if (existing) {
existing.sessions = [...existing.sessions, active];
} else {
out.push({ label: group.label, sessions: [active] });
}
return out;
}
return out;
}
function sortSessions(
sessions: ChatSummary[],
sort: SidebarSortMode,
-148
View File
@@ -1,148 +0,0 @@
import { useState } from "react";
import type { CliAppInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
): CliAppMentionSegment[] {
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
const appsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
if (appsByName.size === 0) return [{ kind: "text", text: value }];
const segments: CliAppMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const app = appsByName.get(name.toLowerCase());
if (!app) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
}: {
text: string;
cliApps: CliAppInfo[];
}) {
const segments = splitCliAppMentionSegments(text, cliApps);
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function CliAppMentionToken({
app,
label,
variant,
isHero = false,
}: {
app: CliAppInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const showLogo = Boolean(app.logo_url) && !failed;
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={app.logo_url ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
@@ -109,46 +109,6 @@ export default function MarkdownTextRenderer({
</a>
);
},
img({ src, alt, node: _node, className: imgClassName, ...props }) {
void _node;
const source = typeof src === "string" ? src : "";
if (!source) return null;
const label = typeof alt === "string" ? alt : "";
return (
<span
className={cn(
"not-prose my-3 block w-fit max-w-full overflow-hidden rounded-[14px]",
"border border-border/70 bg-background shadow-sm",
)}
>
<a
href={source}
target="_blank"
rel="noreferrer noopener"
className="block bg-muted/20"
aria-label={label ? `Open ${label}` : "Open image"}
>
<img
src={source}
alt={label}
loading="lazy"
decoding="async"
draggable={false}
className={cn(
"block h-auto max-h-[34rem] max-w-full bg-background object-contain",
imgClassName,
)}
{...props}
/>
</a>
{label ? (
<span className="block max-w-full truncate px-3 py-2 text-xs text-muted-foreground">
{label}
</span>
) : null}
</span>
);
},
}),
[highlightCode],
);
+2 -40
View File
@@ -1,7 +1,6 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -9,18 +8,16 @@ import {
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
}
/**
@@ -35,16 +32,11 @@ interface MessageBubbleProps {
export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
useEffect(() => {
return () => {
@@ -96,7 +88,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
{message.content}
</p>
) : null}
</div>
@@ -166,36 +158,6 @@ export function MessageBubble({
);
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
): CliAppInfo[] {
if (!attachments?.length) return cliApps;
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "cli",
description: existing?.description || "",
requires: existing?.requires || "",
source: existing?.source || "attached",
entry_point: attachment.entry_point || existing?.entry_point || "",
install_supported: existing?.install_supported ?? true,
installed: true,
available: existing?.available ?? true,
status: existing?.status || "installed",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
skill_installed: existing?.skill_installed ?? true,
});
}
return Array.from(byName.values());
}
function MessageMedia({
media,
align,
+9 -29
View File
@@ -37,16 +37,13 @@ export function SessionSearchDialog({
const [highlightedIndex, setHighlightedIndex] = useState(0);
const normalizedQuery = query.trim().toLowerCase();
const sessionResults = useMemo(() => {
if (!open) return [];
const results = useMemo(() => {
if (!normalizedQuery) return sessions;
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
return sessions.filter((session) =>
sessionMatchesTerms(session, terms, titleOverrides[session.key]),
);
}, [normalizedQuery, open, sessions, titleOverrides]);
const itemCount = sessionResults.length;
const shortcutLabel = useMemo(getSearchShortcutLabel, []);
}, [normalizedQuery, sessions, titleOverrides]);
useEffect(() => {
if (!open) return;
@@ -61,9 +58,9 @@ export function SessionSearchDialog({
useEffect(() => {
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : Math.min(index, itemCount - 1),
results.length === 0 ? 0 : Math.min(index, results.length - 1),
);
}, [itemCount]);
}, [results.length]);
const handleSelect = (key: string) => {
onOpenChange(false);
@@ -74,19 +71,17 @@ export function SessionSearchDialog({
if (event.key === "ArrowDown") {
event.preventDefault();
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index + 1) % itemCount,
results.length === 0 ? 0 : Math.min(index + 1, results.length - 1),
);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setHighlightedIndex((index) =>
itemCount === 0 ? 0 : (index - 1 + itemCount) % itemCount,
);
setHighlightedIndex((index) => Math.max(index - 1, 0));
return;
}
if (event.key === "Enter") {
const highlighted = sessionResults[highlightedIndex];
const highlighted = results[highlightedIndex];
if (!highlighted) return;
event.preventDefault();
handleSelect(highlighted.key);
@@ -130,13 +125,9 @@ export function SessionSearchDialog({
aria-label={t("sidebar.searchAria")}
className="h-full min-w-0 flex-1 bg-transparent text-[15px] font-medium text-foreground outline-none placeholder:text-muted-foreground/75"
/>
<kbd className="hidden h-6 shrink-0 items-center rounded-md border border-border/70 bg-muted/60 px-2 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{shortcutLabel}
</kbd>
</div>
<div className="min-h-0 overflow-y-auto overscroll-contain p-2">
<section>
<div className="px-2 pb-1.5 pt-1 text-[12px] font-medium text-muted-foreground/70">
{sectionLabel}
</div>
@@ -145,13 +136,13 @@ export function SessionSearchDialog({
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{t("chat.loading")}
</div>
) : sessionResults.length === 0 ? (
) : results.length === 0 ? (
<div className="px-3 py-7 text-[13px] text-muted-foreground">
{emptyLabel}
</div>
) : (
<ul className="space-y-1">
{sessionResults.map((session, index) => {
{results.map((session, index) => {
const title = titleOverrides[session.key]?.trim() ||
session.title?.trim() ||
deriveTitle(session.preview, t("chat.newChat"));
@@ -198,7 +189,6 @@ export function SessionSearchDialog({
})}
</ul>
)}
</section>
</div>
</DialogContent>
</Dialog>
@@ -221,13 +211,3 @@ function sessionMatchesTerms(
return terms.every((term) => haystack.includes(term));
}
function getSearchShortcutLabel() {
if (typeof navigator === "undefined") return "Ctrl K";
const platform = navigator.platform.toLowerCase();
const apple =
platform.includes("mac") ||
platform.includes("iphone") ||
platform.includes("ipad");
return apple ? "⌘K" : "Ctrl K";
}
+44 -147
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from "react";
import { useState } from "react";
import {
Archive,
ListFilter,
@@ -28,7 +28,6 @@ import type {
SidebarSortMode,
SidebarViewState,
} from "@/lib/types";
import { cn } from "@/lib/utils";
interface SidebarProps {
sessions: ChatSummary[];
@@ -45,9 +44,7 @@ interface SidebarProps {
onToggleArchived: () => void;
onUpdateView: (view: Partial<SidebarViewState>) => void;
onCollapse: () => void;
onExpand?: () => void;
containActionMenus?: boolean;
collapsed?: boolean;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
@@ -62,8 +59,6 @@ export function Sidebar(props: SidebarProps) {
const { t } = useTranslation();
const [menuPortalContainer, setMenuPortalContainer] =
useState<HTMLElement | null>(null);
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
return (
<nav
@@ -71,34 +66,16 @@ export function Sidebar(props: SidebarProps) {
aria-label={t("sidebar.navigation")}
className="flex h-full w-full min-w-0 flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div
className={cn(
"flex items-center px-3 pb-2.5 pt-3",
collapsed ? "w-14 justify-start" : "justify-between",
)}
>
<button
type="button"
aria-label={collapsed ? toggleLabel : undefined}
aria-hidden={collapsed ? undefined : true}
title={collapsed ? toggleLabel : undefined}
onClick={collapsed ? props.onExpand : undefined}
tabIndex={collapsed ? 0 : -1}
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl transition-colors",
collapsed
? "-ml-0.5 hover:bg-sidebar-accent/75"
: "pointer-events-none -ml-0.5",
)}
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-8 w-8 select-none object-contain"
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-6 w-auto select-none object-contain opacity-95"
draggable={false}
/>
</button>
{!collapsed && (
</picture>
<Button
variant="ghost"
size="icon"
@@ -108,48 +85,43 @@ export function Sidebar(props: SidebarProps) {
>
<Menu className="h-3.5 w-3.5" />
</Button>
)}
</div>
<div
className={cn(
"space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
<div className="space-y-1.5 px-2 pb-2">
<Button
onClick={props.onNewChat}
icon={<SquarePen className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.searchAria")}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")}
</Button>
<Button
type="button"
onClick={props.onOpenSearch}
icon={<Search className="h-4 w-4" />}
/>
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Search className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.searchAria")}
</Button>
<SidebarViewMenu
compact={collapsed}
view={props.viewState}
onUpdateView={props.onUpdateView}
/>
{props.archivedCount ? (
<SidebarActionButton
collapsed={collapsed}
label={props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
<Button
type="button"
onClick={props.onToggleArchived}
icon={<Archive className="h-4 w-4" />}
/>
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<Archive className="h-3.5 w-3.5" aria-hidden />
{props.showArchived ? t("chat.hideArchived") : t("chat.showArchived")}
</Button>
) : null}
</div>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
collapsed && "pointer-events-none opacity-0",
)}
>
{!collapsed && (
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatList
sessions={props.sessions}
activeKey={props.activeKey}
@@ -174,86 +146,28 @@ export function Sidebar(props: SidebarProps) {
props.containActionMenus ? menuPortalContainer : undefined
}
/>
)}
</div>
<Separator className="bg-sidebar-border/50" />
<div
className={cn(
"flex items-center gap-1 px-2.5 py-2.5 text-xs",
collapsed && "w-14 flex-col px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.settings")}
<div className="flex items-center gap-1 px-2.5 py-2.5 text-xs">
<Button
type="button"
variant="ghost"
onClick={props.onOpenSettings}
className={collapsed ? undefined : "flex-1"}
icon={<Settings className="h-4 w-4" />}
/>
className="h-8 min-w-0 flex-1 justify-start gap-2 rounded-full px-2.5 text-[12.5px] font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
>
<Settings className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.settings")}
</Button>
<ConnectionBadge />
</div>
</nav>
);
}
function SidebarActionButton({
collapsed,
label,
icon,
onClick,
className,
}: {
collapsed: boolean;
label: string;
icon: ReactNode;
onClick: () => void;
className?: string;
}) {
return (
<Button
type="button"
variant="ghost"
aria-label={label}
title={collapsed ? label : undefined}
onClick={onClick}
className={cn(
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
className,
)}
>
<span
className={cn(
"flex shrink-0 items-center justify-center transition-transform duration-300 ease-out",
collapsed ? "translate-x-0" : "translate-x-0",
)}
aria-hidden
>
{icon}
</span>
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
collapsed
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
{label}
</span>
</Button>
);
}
function SidebarViewMenu({
compact = false,
view,
onUpdateView,
}: {
compact?: boolean;
view?: SidebarViewState;
onUpdateView: (view: Partial<SidebarViewState>) => void;
}) {
@@ -268,28 +182,11 @@ function SidebarViewMenu({
<DropdownMenuTrigger asChild>
<Button
type="button"
aria-label={t("sidebar.viewOptions")}
title={compact ? t("sidebar.viewOptions") : undefined}
className={cn(
"h-8 min-w-0 overflow-hidden font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
compact
? "w-9 justify-center gap-0 rounded-xl px-0"
: "w-full justify-start gap-2 rounded-full px-3 text-[12.5px]",
)}
className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/75 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost"
>
<ListFilter className="h-4 w-4 shrink-0" aria-hidden />
<span
className={cn(
"min-w-0 overflow-hidden truncate whitespace-nowrap transition-[max-width,opacity,transform] duration-200 ease-out",
compact
? "max-w-0 -translate-x-1 opacity-0"
: "max-w-[12rem] translate-x-0 opacity-100",
)}
>
<ListFilter className="h-3.5 w-3.5" aria-hidden />
{t("sidebar.viewOptions")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
+2 -587
View File
@@ -32,8 +32,6 @@ import {
Loader2,
LogOut,
Moon,
Package,
PlayCircle,
Orbit,
Palette,
Pencil,
@@ -43,7 +41,6 @@ import {
ShieldCheck,
SlidersHorizontal,
Sparkles,
Trash2,
Triangle,
Waves,
Zap,
@@ -62,19 +59,14 @@ import {
import { Input } from "@/components/ui/input";
import {
fetchSettings,
fetchCliApps,
runCliAppAction,
updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
updateWebSearchSettings,
} from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
SettingsPayload,
WebSearchSettingsUpdate,
@@ -87,7 +79,6 @@ type SettingsSectionKey =
| "providers"
| "image"
| "web"
| "cliApps"
| "runtime"
| "advanced";
@@ -98,7 +89,6 @@ interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
@@ -120,7 +110,6 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
};
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
@@ -157,7 +146,6 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
};
} catch {
return DEFAULT_LOCAL_PREFS;
@@ -189,11 +177,8 @@ export function SettingsView({
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
@@ -201,12 +186,6 @@ export function SettingsView({
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState("");
const [cliAppsQuery, setCliAppsQuery] = useState("");
const [cliAppsCategory, setCliAppsCategory] = useState("all");
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
@@ -306,27 +285,6 @@ export function SettingsView({
};
}, [applyPayload, token]);
useEffect(() => {
let cancelled = false;
setCliAppsLoading(true);
fetchCliApps(token)
.then((payload) => {
if (!cancelled) {
setCliApps(payload);
setCliAppsError(null);
}
})
.catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message);
})
.finally(() => {
if (!cancelled) setCliAppsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
@@ -616,29 +574,6 @@ export function SettingsView({
});
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
setCliApps(payload);
if (action !== "test") {
notifyCliAppsChanged(payload);
}
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
@@ -683,7 +618,6 @@ export function SettingsView({
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
@@ -743,26 +677,6 @@ export function SettingsView({
requiresRestartPending={pendingRestartSections.web}
/>
);
case "cliApps":
return (
<CliAppsSettings
payload={cliApps}
loading={cliAppsLoading}
query={cliAppsQuery}
category={cliAppsCategory}
installFilter={cliAppsInstallFilter}
actionKey={cliAppsAction}
message={cliAppsMessage}
error={cliAppsError}
focusName={cliAppsFocusName}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setCliAppsQuery}
onCategoryChange={setCliAppsCategory}
onInstallFilterChange={setCliAppsInstallFilter}
onAction={handleCliAppAction}
onBackToChat={onBackToChat}
/>
);
case "runtime":
return (
<RuntimeSettings
@@ -838,7 +752,6 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
{ key: "providers", icon: KeyRound, fallback: "Providers" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "web", icon: Globe2, fallback: "Web" },
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
{ key: "runtime", icon: Server, fallback: "Runtime" },
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
];
@@ -1164,16 +1077,6 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
@@ -1308,7 +1211,6 @@ function ProvidersSettings({
editingProviderKeys,
providerSaving,
query,
showBrandLogos,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
@@ -1327,7 +1229,6 @@ function ProvidersSettings({
editingProviderKeys: Record<string, boolean>;
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
@@ -1371,10 +1272,7 @@ function ProvidersSettings({
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon
provider={provider.name}
showBrandLogos={showBrandLogos}
/>
<ProviderIcon provider={provider.name} />
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
@@ -1539,7 +1437,6 @@ function ProvidersSettings({
>
{filteredUnconfigured.map(renderProviderRow)}
</ProviderSection>
<ThirdPartyBrandNotice />
</div>
);
}
@@ -1934,383 +1831,6 @@ function WebSettings({
);
}
function CliAppsSettings({
payload,
loading,
query,
category,
installFilter,
actionKey,
message,
error,
focusName,
showBrandLogos,
onQueryChange,
onCategoryChange,
onInstallFilterChange,
onAction,
onBackToChat,
}: {
payload: CliAppsPayload | null;
loading: boolean;
query: string;
category: string;
installFilter: "all" | "installed" | "notInstalled";
actionKey: string | null;
message: string | null;
error: string | null;
focusName: string | null;
showBrandLogos: boolean;
onQueryChange: (value: string) => void;
onCategoryChange: (value: string) => void;
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const apps = payload?.apps ?? [];
const categories = useMemo(
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
[apps],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
const categoryMatch = category === "all" || app.category === category;
if (!categoryMatch) return false;
if (installFilter === "installed" && !app.installed) return false;
if (installFilter === "notInstalled" && app.installed) return false;
if (!normalizedQuery) return true;
return (
app.display_name.toLowerCase().includes(normalizedQuery) ||
app.name.toLowerCase().includes(normalizedQuery) ||
app.description.toLowerCase().includes(normalizedQuery) ||
app.category.toLowerCase().includes(normalizedQuery)
);
});
const categoryLabel =
category === "all"
? tx("settings.cliApps.allCategories", "All categories")
: category;
const installFilterOptions = [
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
];
const focusedApp = focusName
? apps.find((app) => app.name === focusName && app.installed)
: null;
const visibleStatusMessage = error || (!focusedApp ? message : null);
return (
<div className="space-y-5">
<section className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
<p className="mt-1 text-[13px] text-muted-foreground">
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
.replace("{{installed}}", String(payload?.installed_count ?? 0))
.replace("{{total}}", String(apps.length))}
</p>
</div>
<SegmentedControl
value={installFilter}
options={installFilterOptions}
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
<span className="max-w-[180px] truncate">{categoryLabel}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
{categories.map((item) => (
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</section>
{visibleStatusMessage ? (
<div
className={cn(
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{visibleStatusMessage}
</div>
) : null}
{focusedApp ? (
<CliAppReadyPanel
app={focusedApp}
showBrandLogos={showBrandLogos}
onBackToChat={onBackToChat}
/>
) : null}
{loading ? (
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
</div>
) : (
<section>
<div className="grid gap-2">
{filteredApps.map((app) => (
<CliAppCard
key={app.name}
app={app}
actionKey={actionKey}
showBrandLogos={showBrandLogos}
onAction={onAction}
/>
))}
</div>
{!filteredApps.length ? (
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
</div>
) : null}
</section>
)}
<ThirdPartyBrandNotice />
</div>
);
}
function CliAppReadyPanel({
app,
showBrandLogos,
onBackToChat,
}: {
app: CliAppInfo;
showBrandLogos: boolean;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const prompt = t("settings.cliApps.readyPrompt", {
name: app.name,
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
});
const copyPrompt = () => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
});
};
return (
<section
className={cn(
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
</span>
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="font-mono">@{app.name}</span>
<span aria-hidden>·</span>
<span className="truncate font-mono">{app.entry_point || app.name}</span>
<span aria-hidden>·</span>
<span>{app.category}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={copyPrompt}
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
>
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{copied
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
</Button>
<Button
type="button"
size="sm"
onClick={onBackToChat}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
</section>
);
}
function CliAppCard({
app,
actionKey,
showBrandLogos,
onAction,
}: {
app: CliAppInfo;
actionKey: string | null;
showBrandLogos: boolean;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const installBusy = actionKey === `install:${app.name}`;
const updateBusy = actionKey === `update:${app.name}`;
const uninstallBusy = actionKey === `uninstall:${app.name}`;
const testBusy = actionKey === `test:${app.name}`;
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
return (
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
{app.category}
</span>
</div>
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
{app.entry_point || app.name}
</div>
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
{app.requires
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
</p>
</div>
<div className="shrink-0">
{app.installed ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.cliApps.statusInstalled", "CLI installed")}
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : app.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => onAction("install", app.name)}
className="h-8 rounded-full px-4 text-[12px] font-semibold"
>
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{tx("settings.cliApps.install", "Install CLI")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{tx("settings.cliApps.unavailable", "Unavailable")}
</Button>
)}
</div>
</article>
);
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
const [failed, setFailed] = useState(false);
const bg = app.brand_color || "hsl(var(--muted))";
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
if (showBrandLogos && app.logo_url && !failed) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
>
<img
src={app.logo_url}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
function RuntimeSettings({
form,
setForm,
@@ -2559,18 +2079,6 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
@@ -2618,63 +2126,6 @@ function providerLabel(
return providers.find((provider) => provider.name === value)?.label ?? value;
}
interface ProviderBrand {
logoUrl: string;
color: string;
initials: string;
}
function faviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
}
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
byteplus_coding_plan: "byteplus",
minimax_anthropic: "minimax",
openai_codex: "openai",
volcengine_coding_plan: "volcengine",
};
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
};
function providerBrand(provider: string): ProviderBrand | null {
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
return PROVIDER_BRANDS[key] ?? null;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@@ -2709,44 +2160,8 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
nvidia: Zap,
};
function ProviderIcon({
provider,
showBrandLogos,
}: {
provider: string;
showBrandLogos: boolean;
}) {
const [failed, setFailed] = useState(false);
const brand = providerBrand(provider);
function ProviderIcon({ provider }: { provider: string }) {
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
if (showBrandLogos && brand?.logoUrl && !failed) {
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
>
<img
src={brand.logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
@@ -1,12 +1,11 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cliAppInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
import type { UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
@@ -25,17 +24,13 @@ export function isAgentActivityMember(m: UIMessage): boolean {
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
cliCount: number;
fileCount: number;
added: number;
deleted: number;
hasDiffStats: boolean;
hasEditingFiles: boolean;
hasFailedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
primaryCliStatus?: CliRunStatus;
}
interface FileEditSummary {
@@ -51,46 +46,21 @@ interface FileEditSummary {
error?: string;
}
interface CliRunSummary {
key: string;
name: string;
args: string[];
json: boolean;
workingDir?: string;
status: CliRunStatus;
error?: string;
}
type CliRunStatus = "running" | "done" | "error";
function countActivity(
messages: UIMessage[],
fileEdits: FileEditSummary[],
cliRuns: CliRunSummary[],
): ActivityCounts {
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
const cliCount = cliRuns.length;
const primaryCli = cliRuns[cliRuns.length - 1];
const primaryCliName = primaryCli?.name;
const primaryCliStatus = primaryCli?.status;
for (const m of messages) {
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = traceLines(m);
for (const line of lines) {
if (!isCliRunTraceLine(line)) {
toolCalls += 1;
}
}
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += lines;
}
}
let added = 0;
let deleted = 0;
let hasDiffStats = false;
let hasEditingFiles = false;
let failedFileCount = 0;
let primaryFilePath: string | undefined;
@@ -107,27 +77,19 @@ function countActivity(
if (edit.status === "error" || edit.binary) {
continue;
}
if (!hasVisibleDiffStats(edit)) {
continue;
}
hasDiffStats = true;
added += edit.added;
deleted += edit.deleted;
}
return {
reasoningSteps,
toolCalls,
cliCount,
fileCount: fileEdits.length,
added,
deleted,
hasDiffStats,
hasEditingFiles,
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
};
}
@@ -136,7 +98,6 @@ interface AgentActivityClusterProps {
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
isTurnStreaming: boolean;
hasBodyBelow: boolean;
cliApps?: CliAppInfo[];
}
/**
@@ -147,33 +108,23 @@ export function AgentActivityCluster({
messages,
isTurnStreaming,
hasBodyBelow,
cliApps = [],
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
[messages, isTurnStreaming],
);
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
const cliAppsByName = useMemo(
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
[cliApps],
);
const {
reasoningSteps,
toolCalls,
cliCount,
fileCount,
added,
deleted,
hasDiffStats,
hasEditingFiles,
hasFailedFiles,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
} = countActivity(messages, fileEdits, cliRuns);
} = countActivity(messages, fileEdits);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false);
@@ -189,7 +140,6 @@ export function AgentActivityCluster({
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
@@ -205,22 +155,8 @@ export function AgentActivityCluster({
})
: "";
const cliActivitySummary = cliCount > 0
? cliCount === 1 && primaryCliName
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
name: primaryCliName,
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
})
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
count: cliCount,
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
})
: "";
const summary = fileCount > 0
? fileActivitySummary
: cliCount > 0
? cliActivitySummary
: isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
@@ -307,10 +243,6 @@ export function AgentActivityCluster({
autoFollowActivityRef.current = distance < ACTIVITY_SCROLL_NEAR_BOTTOM_PX;
}, []);
if (!hasVisibleActivity) return null;
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@@ -323,7 +255,7 @@ export function AgentActivityCluster({
aria-expanded={outerExpanded}
aria-label={summary}
>
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
{singleFilePath ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
@@ -350,7 +282,7 @@ export function AgentActivityCluster({
{summary}
</StreamingLabelSheen>
)}
{fileCount > 0 && hasDiffStats && (
{fileCount > 0 && (
<span className="inline-flex min-w-0 items-center gap-1 text-muted-foreground/85">
<DiffPair added={added} deleted={deleted} />
</span>
@@ -394,25 +326,15 @@ export function AgentActivityCluster({
);
}
if (m.kind === "trace") {
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
return normalLines.length > 0 ? (
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
return hasTraceLines ? (
<div key={m.id} className="flex flex-col gap-1">
<TraceGroup
message={{
...m,
traces: normalLines,
content: normalLines[normalLines.length - 1],
}}
animClass=""
/>
<TraceGroup message={m} animClass="" />
</div>
) : null;
}
return null;
})}
{cliRuns.length ? (
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
) : null}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
</div>
</div>
@@ -426,181 +348,6 @@ function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
function traceLines(message: UIMessage): string[] {
if (message.traces?.length) return message.traces;
return message.content.trim() ? [message.content] : [];
}
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
function isCliRunTraceLine(line: string): boolean {
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
const argsText = match[2].trim();
let argsObject: unknown = {};
if (argsText) {
try {
argsObject = JSON.parse(argsText);
} catch {
return {
key: line,
name: "cli",
args: [argsText],
json: false,
status,
};
}
}
return cliRunFromArguments(argsObject, { key: line, status });
}
function parseToolEventArguments(event: ToolProgressEvent): unknown {
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
const raw = fnArgs ?? event.arguments;
if (typeof raw !== "string") return raw ?? {};
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch {
return { args: [raw] };
}
}
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
if (phase === "error") return "error";
if (phase === "end") return "done";
return "running";
}
function cliRunError(event: ToolProgressEvent): string | undefined {
const error = event.error;
if (typeof error === "string") return error;
if (error && typeof error === "object") return JSON.stringify(error);
return undefined;
}
function cliRunFromArguments(
argsObject: unknown,
options: { key: string; status: CliRunStatus; error?: string },
): CliRunSummary {
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
return {
key: options.key,
name: "cli",
args: [],
json: false,
status: options.status,
error: options.error,
};
}
const record = argsObject as Record<string, unknown>;
const appName = typeof record.name === "string" && record.name.trim()
? record.name.trim()
: "cli";
const rawArgs = Array.isArray(record.args) ? record.args : [];
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
return {
key: options.key,
name: appName,
args: cliArgs,
json: record.json === true || record.json === "true",
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
status: options.status,
error: options.error,
};
}
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
const name =
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
const argsObject = parseToolEventArguments(event);
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
return cliRunFromArguments(argsObject, {
key,
status: cliRunStatusFromPhase(event.phase),
error: cliRunError(event),
});
}
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
if (!existing) return incoming;
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
? { ...existing, ...incoming }
: existing;
}
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
const runsByKey = new Map<string, CliRunSummary>();
for (const message of messages) {
if (message.kind !== "trace") continue;
let hasStructuredCliRun = false;
for (const event of message.toolEvents ?? []) {
const run = cliRunFromEvent(event);
if (!run) continue;
hasStructuredCliRun = true;
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
}
if (hasStructuredCliRun) continue;
for (const line of traceLines(message)) {
const run = parseCliRunTrace(line);
if (!run || runsByKey.has(run.key)) continue;
runsByKey.set(run.key, run);
}
}
return [...runsByKey.values()];
}
function displayCliArg(arg: string): string {
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
}
function formatCliArgs(run: CliRunSummary): string {
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
return args.join(" ");
}
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "message.cliActivityFailedOne";
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
}
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "CLI failed @{{name}}";
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
}
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
return active && runs.some((run) => run.status === "running")
? "message.cliActivityRunningMany"
: "message.cliActivityRanMany";
}
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
}
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "message.cliRunFailed";
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
}
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "CLI failed";
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
if (failed) return "Failed";
return editing ? "Editing" : "Edited";
@@ -688,17 +435,6 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
summary.absolute_path = edit.absolute_path;
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.approximate = summary.approximate || !!edit.approximate;
if (!edit.binary) {
summary.added += edit.added;
summary.deleted += edit.deleted;
}
}
continue;
}
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.binary = summary.binary || !!edit.binary;
@@ -725,16 +461,8 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
}
}
return order.flatMap((key) => {
return order.map((key) => {
const summary = byPath.get(key)!;
if (
!summary.path
&& !summary.hasActiveEditing
&& !summary.hasSuccessfulChange
&& !summary.hasFailed
) {
return [];
}
const status: UIFileEdit["status"] = summary.hasActiveEditing
? "editing"
: summary.hasSuccessfulChange
@@ -742,7 +470,7 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
: summary.hasFailed
? "error"
: "done";
return [{
return {
key: summary.key,
path: summary.path,
absolute_path: summary.absolute_path,
@@ -753,128 +481,10 @@ function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSumma
status,
pending: summary.pending && !summary.path,
error: summary.error,
}];
};
});
}
function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">): boolean {
return edit.added > 0 || edit.deleted > 0;
}
function CliRunGroup({
runs,
active,
cliAppsByName,
}: {
runs: CliRunSummary[];
active: boolean;
cliAppsByName: Map<string, CliAppInfo>;
}) {
if (runs.length === 0) return null;
return (
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
{runs.map((run) => (
<CliRunRow
key={run.key}
run={run}
active={active}
app={cliAppsByName.get(run.name.toLowerCase())}
/>
))}
</ul>
);
}
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
const { t } = useTranslation();
const [logoFailed, setLogoFailed] = useState(false);
const args = formatCliArgs(run);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
return (
<li
className={cn(
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
)}
style={{
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
}}
>
<div className="flex min-w-0 items-center gap-2">
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 26),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
}}
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[70%] w-[70%] object-contain"
onError={() => setLogoFailed(true)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3.5 w-3.5" aria-hidden />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
{t(cliRunLabelKey(run, active), {
defaultValue: cliRunLabelDefault(run, active),
})}
</StreamingLabelSheen>
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
@{run.name}
</span>
{failed ? (
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
) : null}
</span>
{args ? (
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
{args}
</span>
) : null}
{run.error ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
{run.error}
</span>
) : null}
{run.workingDir ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
{run.workingDir}
</span>
) : null}
</span>
</div>
</li>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
@@ -890,7 +500,7 @@ function FileEditRow({ edit }: { edit: FileEditSummary }) {
const { t } = useTranslation();
const editing = edit.status === "editing";
const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const hasCountedDiff = !failed && !edit.binary;
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-2 py-1.5 text-xs">
<div className="flex min-w-0 items-center gap-2">
+12 -341
View File
@@ -9,16 +9,9 @@ import {
} from "react";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
cliAppInitials,
splitCliAppMentionSegments,
type CliAppMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Check,
ChevronDown,
@@ -48,7 +41,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@@ -69,7 +62,6 @@ interface ThreadComposerProps {
modelLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -106,12 +98,6 @@ interface SlashPaletteLayout {
maxHeight: number;
}
interface CliAppMentionQuery {
query: string;
start: number;
end: number;
}
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
@@ -181,17 +167,6 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
return o || s;
}
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
return {
name: app.name,
display_name: app.display_name,
category: app.category,
entry_point: app.entry_point,
logo_url: app.logo_url ?? null,
brand_color: app.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@@ -396,7 +371,6 @@ export function ThreadComposer({
modelLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@@ -408,9 +382,6 @@ export function ThreadComposer({
const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
const [cursorPosition, setCursorPosition] = useState(0);
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
@@ -520,52 +491,6 @@ export function ThreadComposer({
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (disabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
if (!match) return null;
const query = match[1].toLowerCase();
return {
query,
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredCliApps = useMemo(() => {
if (!cliAppMention) return [];
return cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
app.name,
app.display_name,
app.category,
app.description,
app.entry_point,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.slice(0, 8);
}, [cliAppMention, cliApps]);
const showCliAppMenu = filteredCliApps.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
seen.add(segment.app.name);
return [segment.app];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -575,10 +500,6 @@ export function ThreadComposer({
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
setSelectedCliAppIndex(0);
}, [cliAppMention?.query]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
@@ -586,29 +507,22 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (selectedCliAppIndex >= filteredCliApps.length) {
setSelectedCliAppIndex(0);
}
}, [filteredCliApps.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
if (!showSlashMenu) return;
const dismissOnPointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && formRef.current?.contains(target)) return;
setSlashMenuDismissed(true);
setCliAppMenuDismissed(true);
};
document.addEventListener("pointerdown", dismissOnPointerDown, true);
return () => {
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
};
}, [showAnyPalette]);
}, [showSlashMenu]);
useLayoutEffect(() => {
if (!showAnyPalette) return;
if (!showSlashMenu) return;
const updateLayout = () => {
const form = formRef.current;
@@ -640,7 +554,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
}, [filteredSlashCommands.length, showSlashMenu]);
useEffect(() => {
if (!aspectMenuOpen) return;
@@ -688,36 +602,12 @@ export function ThreadComposer({
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
setCursorPosition(nextCursor);
setCliAppMenuDismissed(true);
setSlashMenuDismissed(false);
setInlineError(null);
resizeTextarea();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextCursor, nextCursor);
});
},
[cliAppMention, resizeTextarea, value],
);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
@@ -735,20 +625,13 @@ export function ThreadComposer({
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0
? {
...(imageMode
const options: SendOptions | undefined = imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
}
: undefined;
onSend(trimmed, payload, options);
setValue("");
@@ -757,36 +640,10 @@ export function ThreadComposer({
// preview here without affecting the rendered message.
clear();
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setCliAppMenuDismissed(true);
return;
}
}
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -862,12 +719,6 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
return (
<form
@@ -892,16 +743,6 @@ export function ThreadComposer({
onChoose={chooseSlashCommand}
/>
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
apps={filteredCliApps}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseCliApp}
/>
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
@@ -946,42 +787,30 @@ export function ThreadComposer({
{runStartedAt != null || goalState?.active ? (
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<div className="relative">
{hasCliMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
className={inputTextClasses}
/>
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
"placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
{inlineError ? (
<div
role="alert"
@@ -1133,40 +962,6 @@ export function ThreadComposer({
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CliAppMentionSegment[];
isHero: boolean;
className: string;
}) {
return (
<div
aria-hidden
className={cn(
className,
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
)}
>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
}
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
@@ -1176,15 +971,6 @@ interface SlashCommandPaletteProps {
onChoose: (command: SlashCommand) => void;
}
interface CliAppMentionPaletteProps {
apps: CliAppInfo[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (app: CliAppInfo) => void;
}
function ImageAspectMenu({
selected,
isHero,
@@ -1238,121 +1024,6 @@ function ImageAspectMenu({
);
}
function CliAppMentionPalette({
apps,
selectedIndex,
layout,
isHero,
onHover,
onChoose,
}: CliAppMentionPaletteProps) {
const { t } = useTranslation();
const listMaxHeight = Math.max(
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
return (
<div
role="listbox"
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
const selected = index === selectedIndex;
return (
<button
key={app.name}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(app);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function CliAppMentionLogo({
app,
selected,
}: {
app: CliAppInfo;
selected: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
return (
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
)}
>
<img
src={app.logo_url}
alt=""
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{cliAppInitials(app)}
</span>
);
}
function SlashCommandPalette({
commands,
selectedIndex,
+5 -20
View File
@@ -32,17 +32,12 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto"
/>
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
</div>
);
}
@@ -57,7 +52,7 @@ export function ThreadHeader({
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:hidden",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<Menu className="h-3.5 w-3.5" />
@@ -67,12 +62,7 @@ export function ThreadHeader({
</div>
</div>
<ThemeButton
theme={theme}
onToggleTheme={onToggleTheme}
label={t("thread.header.toggleTheme")}
className="ml-auto shrink-0"
/>
<ThemeButton theme={theme} onToggleTheme={onToggleTheme} label={t("thread.header.toggleTheme")} />
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
</div>
@@ -83,12 +73,10 @@ function ThemeButton({
theme,
onToggleTheme,
label,
className,
}: {
theme: "light" | "dark";
onToggleTheme: () => void;
label: string;
className?: string;
}) {
return (
<Button
@@ -96,10 +84,7 @@ function ThemeButton({
size="icon"
aria-label={label}
onClick={onToggleTheme}
className={cn(
"h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground",
className,
)}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
@@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -14,7 +14,6 @@ interface ThreadMessagesProps {
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
}
export type DisplayUnit =
@@ -165,7 +164,6 @@ export function ThreadMessages({
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@@ -210,7 +208,6 @@ export function ThreadMessages({
messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
cliApps={cliApps}
/>
) : (
<MessageBubble
@@ -220,7 +217,6 @@ export function ThreadMessages({
? copyFlags[index]
: true
}
cliApps={cliApps}
/>
)}
</div>
+2 -55
View File
@@ -19,13 +19,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import {
CLI_APPS_CHANGED_EVENT,
installedCliAppsFromPayload,
isCliAppsPayload,
} from "@/lib/cli-app-events";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -102,7 +97,6 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -253,50 +247,6 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(installedCliAppsFromPayload(payload));
} catch {
setCliApps([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(installedCliAppsFromPayload(payload));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
const refreshOnCliAppsChanged = (event: Event) => {
const payload = (event as CustomEvent<unknown>).detail;
if (isCliAppsPayload(payload)) {
setCliApps(installedCliAppsFromPayload(payload));
return;
}
void refreshCliApps();
};
window.addEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
window.removeEventListener(CLI_APPS_CHANGED_EVENT, refreshOnCliAppsChanged);
};
}, [refreshCliApps, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -382,7 +332,6 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@@ -402,7 +351,6 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@@ -443,7 +391,6 @@ export function ThreadShell({
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
/>
</section>
);
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CliAppInfo, UIMessage } from "@/lib/types";
import type { UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@@ -24,7 +24,6 @@ interface ThreadViewportProps {
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
}
const NEAR_BOTTOM_PX = 48;
@@ -54,7 +53,6 @@ export function ThreadViewport({
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -251,7 +249,6 @@ export function ThreadViewport({
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
/>
</div>
</div>
-46
View File
@@ -216,52 +216,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;
+7 -62
View File
@@ -2,16 +2,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import {
mergeToolProgressEvents,
mergeUniqueToolTraceLines,
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@@ -193,15 +187,6 @@ function stampLastAssistantLatency(prev: UIMessage[], latencyMs: number): UIMess
return prev;
}
function findLatestAssistantAnswerIndex(prev: UIMessage[]): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.role === "assistant" && m.kind !== "trace") return i;
if (m.role === "user") break;
}
return null;
}
function absorbCompleteAssistantMessage(
prev: UIMessage[],
message: Omit<UIMessage, "id" | "role" | "createdAt">,
@@ -312,7 +297,6 @@ export interface SendImage {
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
}
export function useNanobotStream(
@@ -498,54 +482,23 @@ export function useNanobotStream(
[appendAnswerChunk, ensureActivitySegmentId],
);
const flushPendingStreamEvents = useCallback((options?: {
closeAnswerSegment?: boolean;
finalAnswerText?: string;
}) => {
const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
if (events.length === 0 && finalAnswerText === undefined) {
if (events.length === 0) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
}
pendingStreamEventsRef.current = [];
setMessages((prev) => {
let next = events.length > 0 ? applyPendingStreamEvents(prev, events) : prev;
if (finalAnswerText !== undefined) {
const targetIndex =
resolveActiveAssistantIndex(next)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current)
?? findLatestAssistantAnswerIndex(next);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
content: finalAnswerText,
isStreaming: true,
});
} else {
const id = crypto.randomUUID();
closedAssistantStreamIdsRef.current.add(id);
next = [
...next,
{
id,
role: "assistant",
content: finalAnswerText,
isStreaming: true,
createdAt: Date.now(),
},
];
}
}
const next = applyPendingStreamEvents(prev, events);
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
});
}, [applyPendingStreamEvents, closeActiveAssistantStream, resolveActiveAssistantIndex]);
}, [applyPendingStreamEvents, closeActiveAssistantStream]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
@@ -623,10 +576,7 @@ export function useNanobotStream(
}
if (ev.event === "stream_end") {
flushPendingStreamEvents({
closeAnswerSegment: true,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
});
flushPendingStreamEvents({ closeAnswerSegment: true });
if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
@@ -707,7 +657,6 @@ export function useNanobotStream(
// Attach them to the last trace row if it was the last emitted item
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
const lines = structuredLines.length > 0
? structuredLines
@@ -732,15 +681,13 @@ export function useNanobotStream(
const mergedLines = structuredLines.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
if (mergedLines && !mergedLines.added) return prev;
const merged: UIMessage = {
...last,
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
toolEvents: structuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
@@ -753,7 +700,6 @@ export function useNanobotStream(
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
@@ -890,7 +836,6 @@ export function useNanobotStream(
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
},
];
});
+1 -15
View File
@@ -27,25 +27,13 @@ export function useSessions(): {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const tokenRef = useRef(token);
const optimisticKeysRef = useRef<Set<string>>(new Set());
tokenRef.current = token;
const refresh = useCallback(async () => {
try {
setLoading(true);
const rows = await listSessions(tokenRef.current);
const serverKeys = new Set(rows.map((row) => row.key));
setSessions((prev) => [
...rows,
...prev.filter(
(session) =>
optimisticKeysRef.current.has(session.key) &&
!serverKeys.has(session.key),
),
]);
for (const key of Array.from(optimisticKeysRef.current)) {
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
}
setSessions(rows);
setError(null);
} catch (e) {
const msg =
@@ -69,7 +57,6 @@ export function useSessions(): {
const createChat = useCallback(async (): Promise<string> => {
const chatId = await client.newChat();
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the
// authoritative row once the server persists the session.
setSessions((prev) => [
@@ -90,7 +77,6 @@ export function useSessions(): {
const deleteChat = useCallback(
async (key: string) => {
await apiDeleteSession(tokenRef.current, key);
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
},
[],
-56
View File
@@ -80,7 +80,6 @@
"providers": "Providers",
"image": "Image",
"web": "Web",
"cliApps": "CLI Apps",
"runtime": "Runtime",
"advanced": "Advanced"
},
@@ -95,7 +94,6 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
@@ -117,7 +115,6 @@
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
@@ -144,8 +141,6 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs"
},
"help": {
@@ -159,7 +154,6 @@
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"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.",
@@ -174,41 +168,8 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"cliApps": {
"allCategories": "All categories",
"availableCount": "{{count}} apps",
"installedCount": "{{count}} CLIs installed",
"summary": "{{installed}} of {{total}} CLIs installed",
"filterAll": "All",
"filterInstalled": "Installed CLIs",
"filterNotInstalled": "Not installed",
"searchPlaceholder": "Search CLIs",
"loading": "Loading CLI Apps...",
"empty": "No CLI Apps match this filter.",
"statusInstalled": "CLI installed",
"statusMissing": "Missing",
"statusAvailable": "Available",
"statusUnsupported": "Unsupported",
"statusNotInstalled": "CLI not installed",
"requires": "Requires",
"test": "Test CLI",
"update": "Update CLI",
"uninstall": "Uninstall CLI",
"install": "Install CLI",
"readyTitle": "@{{name}} is ready",
"readyStatus": "Ready",
"readyTry": "Try @{{name}}",
"readyCopied": "Copied",
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
"openChat": "Open chat",
"unsupported": "Unsupported",
"unavailable": "Unavailable",
"noDescription": "No description available."
},
"values": {
"light": "Light",
"dark": "Dark",
@@ -298,9 +259,6 @@
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
},
"image": {
"selectProvider": "Select provider",
"selectAspect": "Select aspect",
@@ -313,7 +271,6 @@
"fallbackTitle": "Chat {{id}}",
"loading": "Loading…",
"noSessions": "No sessions yet.",
"showMore": "Show {{count}} more",
"actions": "Chat actions for {{title}}",
"activity": {
"running": "Agent running",
@@ -504,10 +461,6 @@
}
}
},
"mentions": {
"ariaLabel": "CLI Apps",
"label": "CLI APPS"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@@ -539,15 +492,6 @@
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"cliActivityRunningOne": "Running CLI @{{name}}",
"cliActivityRanOne": "Ran CLI @{{name}}",
"cliActivityFailedOne": "CLI failed @{{name}}",
"cliActivityRunningMany": "Running {{count}} CLIs",
"cliActivityRanMany": "Ran {{count}} CLIs",
"cliActivityFailedMany": "{{count}} CLI failed",
"cliRunRunning": "Running CLI",
"cliRunRan": "Ran CLI",
"cliRunFailed": "CLI failed",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",

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