diff --git a/README.md b/README.md
index 2d76f48be..3f9802250 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,7 @@
-
+
+
@@ -44,6 +47,13 @@ | Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) | | Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) | +## Open Source Partners + +
+ ## 📢 News - **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details. diff --git a/docs/configuration.md b/docs/configuration.md index dc19dadd4..d34a6cd30 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -231,7 +231,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client | `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) | +| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) | diff --git a/images/readme-cover-dark.png b/images/readme-cover-dark.png new file mode 100644 index 000000000..2f0b8f25b Binary files /dev/null and b/images/readme-cover-dark.png differ diff --git a/images/readme-cover-light.png b/images/readme-cover-light.png new file mode 100644 index 000000000..b36d0e814 Binary files /dev/null and b/images/readme-cover-light.png differ diff --git a/images/readme-cover.png b/images/readme-cover.png deleted file mode 100644 index dbbe43e16..000000000 Binary files a/images/readme-cover.png and /dev/null differ diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index e46d6cda1..f8d93f7ed 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -61,6 +61,7 @@ class MemoryStore: self._cursor_file = self.memory_dir / ".cursor" self._dream_cursor_file = self.memory_dir / ".dream_cursor" self._corruption_logged = False # rate-limit non-int cursor warning + self._malformed_entry_logged = False # rate-limit bad history shape warning self._oversize_logged = False # rate-limit oversized-entry warning self._append_lock = threading.Lock() # serialize cursor allocation + append self._git = GitStore(workspace, tracked_files=[ @@ -295,8 +296,9 @@ class MemoryStore: return value def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]: - """Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption.""" + """Yield ``(entry, cursor)`` for well-formed entries; warn once on corruption.""" poisoned: Any = None + malformed_cursor: int | None = None for entry in self._read_entries(): raw = entry.get("cursor") if raw is None: @@ -305,6 +307,9 @@ class MemoryStore: if cursor is None: poisoned = raw continue + if not self._valid_history_payload(entry): + malformed_cursor = cursor + continue yield entry, cursor if poisoned is not None and not self._corruption_logged: self._corruption_logged = True @@ -313,6 +318,22 @@ class MemoryStore: "Usually caused by an external writer; further occurrences suppressed.", poisoned, ) + if malformed_cursor is not None and not self._malformed_entry_logged: + self._malformed_entry_logged = True + logger.warning( + "history.jsonl contains a malformed entry at cursor {}; dropping it. " + "Usually caused by an external writer; further occurrences suppressed.", + malformed_cursor, + ) + + @staticmethod + def _valid_history_payload(entry: dict[str, Any]) -> bool: + if not isinstance(entry.get("timestamp"), str): + return False + if not isinstance(entry.get("content"), str): + return False + session_key = entry.get("session_key") + return session_key is None or isinstance(session_key, str) def _next_cursor(self) -> int: """Read the current cursor counter and return the next value.""" diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py index 53f6554ab..e5cc579f1 100644 --- a/nanobot/agent/runner.py +++ b/nanobot/agent/runner.py @@ -257,12 +257,17 @@ class AgentRunner: return [] injected_messages: list[dict[str, Any]] = [] for item in items: - if isinstance(item, dict) and item.get("role") == "user" and "content" in item: - injected_messages.append(item) + if item is None: continue - text = getattr(item, "content", str(item)) - if text.strip(): - injected_messages.append({"role": "user", "content": text}) + if isinstance(item, dict) and item.get("role") == "user" and "content" in item: + if self._has_injection_content(item.get("content")): + injected_messages.append(item) + continue + if isinstance(item, dict): + continue + content = getattr(item, "content") if hasattr(item, "content") else str(item) + if self._has_injection_content(content): + injected_messages.append({"role": "user", "content": content}) if len(injected_messages) > _MAX_INJECTIONS_PER_TURN: dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN logger.warning( @@ -272,6 +277,16 @@ class AgentRunner: injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN] return injected_messages + @staticmethod + def _has_injection_content(content: Any) -> bool: + if content is None: + return False + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return bool(content) + return True + async def run(self, spec: AgentRunSpec) -> AgentRunResult: hook = spec.hook or AgentHook() messages = list(spec.initial_messages) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 88c22e610..d93f8419e 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -16,16 +16,16 @@ from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.file_state import FileStates from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.registry import ToolRegistry +from nanobot.bus.events import InboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.config.schema import AgentDefaults, ToolsConfig +from nanobot.providers.base import LLMProvider from nanobot.security.workspace_access import ( WorkspaceScope, bind_workspace_scope, reset_workspace_scope, workspace_sandbox_status, ) -from nanobot.bus.events import InboundMessage -from nanobot.bus.queue import MessageBus -from nanobot.config.schema import AgentDefaults, ToolsConfig -from nanobot.providers.base import LLMProvider from nanobot.utils.prompt_templates import render_template @@ -118,6 +118,7 @@ class SubagentManager: return ToolsConfig( exec=self.tools_config.exec, web=self.tools_config.web, + file=self.tools_config.file, restrict_to_workspace=self.restrict_to_workspace, ) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 6e439495a..9c1854217 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -10,19 +10,36 @@ from typing import Any from nanobot.agent.tools.base import Tool, tool_parameters from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states from nanobot.agent.tools.path_utils import resolve_workspace_path -from nanobot.security.workspace_access import current_tool_workspace from nanobot.agent.tools.schema import ( BooleanSchema, IntegerSchema, StringSchema, tool_parameters_schema, ) +from nanobot.config_base import Base +from nanobot.security.workspace_access import current_tool_workspace from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime +class FileToolsConfig(Base): + """Filesystem tools configuration.""" + + enable: bool = True # built-in file tools on by default + + class _FsTool(Tool): """Shared base for filesystem tools — common init and path resolution.""" + config_key = "file" + + @classmethod + def config_cls(cls): + return FileToolsConfig + + @classmethod + def enabled(cls, ctx: Any) -> bool: + return ctx.config.file.enable + def __init__( self, workspace: Path | None = None, diff --git a/nanobot/api/server.py b/nanobot/api/server.py index 3262e39b2..0fd35a978 100644 --- a/nanobot/api/server.py +++ b/nanobot/api/server.py @@ -54,7 +54,14 @@ def _error_json(status: int, message: str, err_type: str = "invalid_request_erro ) -def _chat_completion_response(content: str, model: str) -> dict[str, Any]: +def _chat_completion_response( + content: str, + model: str, + usage: dict[str, int] | None = None, +) -> dict[str, Any]: + prompt = (usage or {}).get("prompt_tokens", 0) + completion = (usage or {}).get("completion_tokens", 0) + total = (usage or {}).get("total_tokens", 0) or prompt + completion return { "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion", @@ -67,7 +74,11 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]: "finish_reason": "stop", } ], - "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + "usage": { + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": total, + }, } @@ -346,7 +357,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response: logger.exception("Unexpected API lock error for session {}", session_key) return _error_json(500, "Internal server error", err_type="server_error") - return web.json_response(_chat_completion_response(response_text, model_name)) + return web.json_response( + _chat_completion_response(response_text, model_name, getattr(agent_loop, "_last_usage", None)) + ) async def handle_models(request: web.Request) -> web.Response: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 0b7539fdf..93f5dc30d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1282,7 +1282,8 @@ def agent( from nanobot.bus.events import InboundMessage _init_prompt_session() _model, _preset_tag = _model_display(config) - console.print(f"{__logo__} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n") + _icon = config.agents.defaults.bot_icon or __logo__ + console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n") if ":" in session_id: cli_channel, cli_chat_id = session_id.split(":", 1) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e57220f7c..bc0b13491 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -12,6 +12,7 @@ from nanobot.cron.types import CronSchedule if TYPE_CHECKING: from nanobot.agent.tools.cli_apps import CliAppsToolConfig + from nanobot.agent.tools.filesystem import FileToolsConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig @@ -320,6 +321,7 @@ 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")) + file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig")) cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig")) my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig")) image_generation: ImageGenerationToolConfig = Field( @@ -558,6 +560,7 @@ def _resolve_tool_config_refs() -> None: import sys from nanobot.agent.tools.cli_apps import CliAppsToolConfig + from nanobot.agent.tools.filesystem import FileToolsConfig from nanobot.agent.tools.image_generation import ImageGenerationToolConfig from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.shell import ExecToolConfig @@ -566,6 +569,7 @@ 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.FileToolsConfig = FileToolsConfig # 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] diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index ddeb23aed..c9ce4e648 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -452,9 +452,10 @@ class AnthropicProvider(LLMProvider): max_tokens = max(1, max_tokens) thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none" - # claude-opus-4-7 deprecated the `temperature` parameter entirely — the - # API returns 400 if it is present, on any code path. - omit_temperature = "opus-4-7" in model_name + # Several Anthropic models (opus-4-7, opus-4-8, fable) deprecated the + # `temperature` parameter — the API returns 400 if it is present. + _model_lower = model_name.lower() + omit_temperature = any(m in _model_lower for m in ("opus-4-7", "opus-4-8", "fable")) kwargs: dict[str, Any] = { "model": model_name, diff --git a/nanobot/providers/image_generation.py b/nanobot/providers/image_generation.py index 92e2e88eb..47ed22aed 100644 --- a/nanobot/providers/image_generation.py +++ b/nanobot/providers/image_generation.py @@ -1370,6 +1370,8 @@ async def _parse_codex_sse_images( logger.error("Codex SSE failure: {}", raw[:2000]) _collect_images_from_sse_event(event, images) _collect_text_from_sse_event(event, text_parts) + if ev_type == "response.completed": + break continue buffer.append(line) diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 937b7ae41..552c1ce79 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -87,6 +87,21 @@ class TestBuildDreamPrompt: assert "entry-21" in next_prompt assert "entry-25" in next_prompt + def test_skips_malformed_history_entries(self, store): + """Dream prompt building should tolerate externally corrupted JSONL rows.""" + store.history_file.write_text( + '{"cursor": 1, "timestamp": "2026-04-01 10:00"}\n' + '{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "usable memory"}\n', + encoding="utf-8", + ) + + result = store.build_dream_prompt() + + assert result is not None + prompt, cursor = result + assert cursor == 2 + assert "usable memory" in prompt + def test_dream_prompt_consumes_consolidator_attribute_tags(self): prompt = render_template( "agent/dream.md", diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index a9b5d1003..239c62a8d 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -171,6 +171,23 @@ class TestHistoryWithCursor: entries = store.read_unprocessed_history(since_cursor=0) assert [e["cursor"] for e in entries] == [2, 3] + def test_read_unprocessed_skips_malformed_history_payloads(self, store): + """Externally edited JSONL can keep an int cursor but miss required payload fields.""" + store.history_file.write_text( + '{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid"}\n' + '{"cursor": 2, "timestamp": "2026-04-01 10:01"}\n' + '{"cursor": 3, "content": "missing timestamp"}\n' + '{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": 123}\n' + '{"cursor": 5, "timestamp": "2026-04-01 10:04", "content": "bad session", "session_key": 42}\n' + '{"cursor": 6, "timestamp": "2026-04-01 10:05", "content": "also valid", "session_key": "telegram:chat-1"}\n', + encoding="utf-8", + ) + + entries = store.read_unprocessed_history(since_cursor=0) + + assert [e["cursor"] for e in entries] == [1, 6] + assert [e["content"] for e in entries] == ["valid", "also valid"] + def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store): """Regression: _next_cursor should not KeyError on entries without cursor.""" store.history_file.write_text( diff --git a/tests/agent/test_runner_injections.py b/tests/agent/test_runner_injections.py index 3b94569d9..637235ba1 100644 --- a/tests/agent/test_runner_injections.py +++ b/tests/agent/test_runner_injections.py @@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content(): assert result == [{"role": "user", "content": "valid"}] +@pytest.mark.asyncio +async def test_drain_injections_filters_empty_dict_payloads(): + """Pre-normalized dict injections should obey the same empty-content guard.""" + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + runner = AgentRunner(provider) + tools = MagicMock() + tools.get_definitions.return_value = [] + + multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}] + msgs = [ + {"role": "user", "content": ""}, + {"role": "user", "content": " "}, + {"role": "user", "content": None}, + {"role": "assistant", "content": "should not be re-injected as user"}, + None, + {"role": "user", "content": "valid"}, + {"role": "user", "content": multimodal}, + ] + + async def cb(): + return msgs + + spec = AgentRunSpec( + initial_messages=[], tools=tools, model="m", + max_iterations=1, max_tool_result_chars=1000, + injection_callback=cb, + ) + result = await runner._drain_injections(spec) + assert result == [ + {"role": "user", "content": "valid"}, + {"role": "user", "content": multimodal}, + ] + + +@pytest.mark.asyncio +async def test_drain_injections_skips_objects_with_none_content(): + """Objects exposing content=None should be skipped rather than stringified.""" + from types import SimpleNamespace + + from nanobot.agent.runner import AgentRunner, AgentRunSpec + + provider = MagicMock() + runner = AgentRunner(provider) + tools = MagicMock() + tools.get_definitions.return_value = [] + + async def cb(): + return [ + SimpleNamespace(content=None), + SimpleNamespace(content=""), + SimpleNamespace(content="valid"), + ] + + spec = AgentRunSpec( + initial_messages=[], tools=tools, model="m", + max_iterations=1, max_tool_result_chars=1000, + injection_callback=cb, + ) + result = await runner._drain_injections(spec) + assert result == [{"role": "user", "content": "valid"}] + + @pytest.mark.asyncio async def test_drain_injections_handles_callback_exception(): """If the callback raises, return empty list (error is logged).""" @@ -1155,4 +1219,3 @@ async def test_injection_cycle_cap_on_error_path(): assert result.had_injections is True # Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks assert call_count["n"] == _MAX_INJECTION_CYCLES + 1 - diff --git a/tests/agent/test_subagent.py b/tests/agent/test_subagent.py index 5bdfc18dd..32b5aa6ac 100644 --- a/tests/agent/test_subagent.py +++ b/tests/agent/test_subagent.py @@ -6,7 +6,9 @@ from unittest.mock import MagicMock import pytest from nanobot.agent.subagent import SubagentManager +from nanobot.agent.tools.filesystem import FileToolsConfig from nanobot.bus.queue import MessageBus +from nanobot.config.schema import ToolsConfig from nanobot.providers.base import LLMProvider @@ -51,3 +53,29 @@ async def test_subagent_build_tools_isolates_file_read_state(tmp_path): second_result = await second_read.execute(path="note.txt") assert second_result.startswith("1| hello") assert "File unchanged" not in second_result + + +def test_subagent_respects_file_tool_toggle(tmp_path): + provider = MagicMock(spec=LLMProvider) + provider.get_default_model.return_value = "test" + sm = SubagentManager( + provider=provider, + workspace=tmp_path, + bus=MessageBus(), + model="test", + max_tool_result_chars=16_000, + tools_config=ToolsConfig(file=FileToolsConfig(enable=False)), + ) + + tools = sm._build_tools() + + file_tools = { + "apply_patch", + "edit_file", + "find_files", + "grep", + "list_dir", + "read_file", + "write_file", + } + assert file_tools.isdisjoint(tools.tool_names) diff --git a/tests/providers/test_anthropic_thinking.py b/tests/providers/test_anthropic_thinking.py index 9fb22e2e5..547a4817c 100644 --- a/tests/providers/test_anthropic_thinking.py +++ b/tests/providers/test_anthropic_thinking.py @@ -85,6 +85,41 @@ def test_opus_4_7_omits_temperature_none() -> None: assert "thinking" not in kw +def test_opus_4_8_omits_temperature_adaptive() -> None: + kw = _build(_make_provider("claude-opus-4-8"), "adaptive") + assert "temperature" not in kw + + +def test_opus_4_8_omits_temperature_enabled() -> None: + kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096) + assert "temperature" not in kw + + +def test_opus_4_8_omits_temperature_none() -> None: + kw = _build(_make_provider("claude-opus-4-8"), None) + assert "temperature" not in kw + + +def test_fable_omits_temperature_adaptive() -> None: + kw = _build(_make_provider("claude-fable-5"), "adaptive") + assert "temperature" not in kw + + +def test_fable_omits_temperature_enabled() -> None: + kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096) + assert "temperature" not in kw + + +def test_fable_omits_temperature_none() -> None: + kw = _build(_make_provider("claude-fable-5"), None) + assert "temperature" not in kw + + +def test_ordinary_model_sends_temperature() -> None: + kw = _build(_make_provider("claude-sonnet-4-6"), None) + assert kw["temperature"] == 0.7 + + def test_reasoning_effort_string_none_does_not_enable_thinking() -> None: """reasoning_effort='none' must not enable thinking — treated same as disabled.""" kw = _build(_make_provider(), "none") diff --git a/tests/providers/test_image_generation.py b/tests/providers/test_image_generation.py index c42dad493..8ca597fa2 100644 --- a/tests/providers/test_image_generation.py +++ b/tests/providers/test_image_generation.py @@ -84,6 +84,23 @@ class FakeClient: return self.get_response +class CodexStreamingCompleteThenErrorResponse(FakeResponse): + async def aiter_lines(self): + yield 'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}' + yield "" + yield ( + f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1",' + f'"type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}' + ) + yield "" + yield 'data: {"type":"response.completed","response":{"status":"completed"}}' + yield "" + raise httpx.RemoteProtocolError( + "peer closed connection without sending complete message body " + "(incomplete chunked read)" + ) + + @pytest.mark.asyncio async def test_openrouter_image_generation_payload_and_response(tmp_path: Path) -> None: ref = tmp_path / "ref.png" @@ -1024,6 +1041,35 @@ async def test_codex_payload_and_response(monkeypatch) -> None: assert body["stream"] is True +@pytest.mark.asyncio +async def test_codex_stops_reading_after_completed_event(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(CodexStreamingCompleteThenErrorResponse({}, sse_lines=[])) + 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 == "" + + @pytest.mark.asyncio async def test_codex_strips_model_prefix(monkeypatch) -> None: import sys diff --git a/tests/test_api_attachment.py b/tests/test_api_attachment.py index 92e09ef88..3cdbc8476 100644 --- a/tests/test_api_attachment.py +++ b/tests/test_api_attachment.py @@ -32,6 +32,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock: agent.process_direct = AsyncMock(return_value=response_text) agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} return agent diff --git a/tests/test_api_stream.py b/tests/test_api_stream.py index b98e5b8d7..f23339bf4 100644 --- a/tests/test_api_stream.py +++ b/tests/test_api_stream.py @@ -75,6 +75,7 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock: return " ".join(tokens) agent.process_direct = fake_process_direct + agent._last_usage = {} return agent @@ -133,6 +134,7 @@ async def test_stream_false_returns_json(aiohttp_client) -> None: agent.process_direct = AsyncMock(return_value="normal reply") agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -155,6 +157,7 @@ async def test_stream_default_is_false(aiohttp_client) -> None: agent.process_direct = AsyncMock(return_value="default reply") agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -209,6 +212,7 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None: agent.process_direct = fake_process_direct agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -241,6 +245,7 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None: agent.process_direct = fake_process_direct agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -279,6 +284,7 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None agent.process_direct = fake_process_direct agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -320,6 +326,7 @@ async def test_stream_with_session_id(aiohttp_client) -> None: agent.process_direct = fake_process_direct agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -348,6 +355,7 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt agent.process_direct = boom agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) diff --git a/tests/test_file_tool_toggle.py b/tests/test_file_tool_toggle.py new file mode 100644 index 000000000..3df2d00d0 --- /dev/null +++ b/tests/test_file_tool_toggle.py @@ -0,0 +1,44 @@ +from types import SimpleNamespace + +from nanobot.agent.tools.context import ToolContext +from nanobot.agent.tools.file_state import FileStates +from nanobot.agent.tools.filesystem import FileToolsConfig, ReadFileTool +from nanobot.agent.tools.loader import ToolLoader +from nanobot.agent.tools.registry import ToolRegistry +from nanobot.config.schema import Config, ToolsConfig + +FILE_TOOL_NAMES = { + "apply_patch", + "edit_file", + "find_files", + "grep", + "list_dir", + "read_file", + "write_file", +} + + +def test_file_tools_enabled_by_default(): + assert FileToolsConfig().enable is True + assert Config().tools.file.enable is True + + +def test_file_tool_gate_follows_flag(): + cfg = ToolsConfig() + cfg.file.enable = False + assert ReadFileTool.enabled(SimpleNamespace(config=cfg)) is False + assert ReadFileTool.enabled(SimpleNamespace(config=ToolsConfig())) is True + + +def test_file_tool_loader_skips_all_builtin_file_tools_when_disabled(tmp_path): + cfg = ToolsConfig(file=FileToolsConfig(enable=False)) + ctx = ToolContext( + config=cfg, + workspace=str(tmp_path), + file_state_store=FileStates(), + ) + registry = ToolRegistry() + + ToolLoader().load(ctx, registry) + + assert FILE_TOOL_NAMES.isdisjoint(registry.tool_names) diff --git a/tests/test_openai_api.py b/tests/test_openai_api.py index 839c1705d..ba75435a4 100644 --- a/tests/test_openai_api.py +++ b/tests/test_openai_api.py @@ -33,6 +33,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock: agent.process_direct = AsyncMock(return_value=response_text) agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50} return agent @@ -78,6 +79,25 @@ def test_chat_completion_response() -> None: assert result["choices"][0]["message"]["content"] == "hello world" assert result["choices"][0]["finish_reason"] == "stop" assert result["id"].startswith("chatcmpl-") + assert result["usage"]["prompt_tokens"] == 0 + assert result["usage"]["completion_tokens"] == 0 + assert result["usage"]["total_tokens"] == 0 + + +def test_chat_completion_response_with_usage() -> None: + usage = {"prompt_tokens": 150, "completion_tokens": 42} + result = _chat_completion_response("hello world", "test-model", usage) + assert result["usage"]["prompt_tokens"] == 150 + assert result["usage"]["completion_tokens"] == 42 + assert result["usage"]["total_tokens"] == 192 + + +def test_chat_completion_response_preserves_provider_total_usage() -> None: + usage = {"total_tokens": 77} + result = _chat_completion_response("hello world", "test-model", usage) + assert result["usage"]["prompt_tokens"] == 0 + assert result["usage"]["completion_tokens"] == 0 + assert result["usage"]["total_tokens"] == 77 @pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed") @@ -213,6 +233,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None: agent.process_direct = fake_process agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -250,6 +271,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None: agent.process_direct = slow_process agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -364,6 +386,7 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None: agent.process_direct = sometimes_empty agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) @@ -393,6 +416,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None: agent.process_direct = always_empty agent._connect_mcp = AsyncMock() agent.close_mcp = AsyncMock() + agent._last_usage = {} app = create_app(agent, model_name="m") client = await aiohttp_client(app) diff --git a/webui/src/App.tsx b/webui/src/App.tsx index fae72c8ce..ee035c2f4 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -70,6 +70,7 @@ const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs. const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const SIDEBAR_WIDTH = 272; const SIDEBAR_RAIL_WIDTH = 56; +const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`; const TOKEN_REFRESH_MARGIN_MS = 30_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; type ShellView = "chat" | "settings" | "apps" | "automations" | "skills"; @@ -1531,7 +1532,7 @@ function Shell({ showCloseButton={false} aria-describedby={undefined} className="p-0 lg:hidden" - style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }} + style={{ width: MOBILE_SIDEBAR_WIDTH, maxWidth: MOBILE_SIDEBAR_WIDTH }} >