Compare commits
11 Commits
16e87b1b04
...
f65d1a9857
| Author | SHA1 | Date | |
|---|---|---|---|
| f65d1a9857 | |||
|
|
84565d702c | ||
|
|
df7ad91c57 | ||
|
|
337c4600f3 | ||
|
|
dbe9cbc78e | ||
|
|
4e67bea697 | ||
|
|
93f363d4d3 | ||
|
|
ad1e9b2093 | ||
|
|
2eceb6ce8a | ||
|
|
9a652fdd35 | ||
|
|
48fe92a8ad |
16
README.md
16
README.md
@@ -20,9 +20,21 @@
|
|||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
|
||||||
|
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
|
||||||
|
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
|
||||||
|
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
|
||||||
|
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
|
||||||
|
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
|
||||||
|
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
|
||||||
|
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
|
||||||
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
|
||||||
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
|
||||||
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Earlier news</summary>
|
||||||
|
|
||||||
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
|
||||||
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
|
||||||
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
|
||||||
@@ -31,10 +43,6 @@
|
|||||||
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
|
||||||
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
|
||||||
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Earlier news</summary>
|
|
||||||
|
|
||||||
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
|
||||||
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
|
||||||
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
nanobot - A lightweight AI agent framework
|
nanobot - A lightweight AI agent framework
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "0.1.4.post4"
|
__version__ = "0.1.4.post5"
|
||||||
__logo__ = "🐈"
|
__logo__ = "🐈"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""CLI commands for nanobot."""
|
"""CLI commands for nanobot."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from contextlib import contextmanager, nullcontext
|
||||||
import os
|
import os
|
||||||
import select
|
import select
|
||||||
import signal
|
import signal
|
||||||
@@ -168,6 +169,51 @@ async def _print_interactive_response(response: str, render_markdown: bool) -> N
|
|||||||
await run_in_terminal(_write)
|
await run_in_terminal(_write)
|
||||||
|
|
||||||
|
|
||||||
|
class _ThinkingSpinner:
|
||||||
|
"""Spinner wrapper with pause support for clean progress output."""
|
||||||
|
|
||||||
|
def __init__(self, enabled: bool):
|
||||||
|
self._spinner = console.status(
|
||||||
|
"[dim]nanobot is thinking...[/dim]", spinner="dots"
|
||||||
|
) if enabled else None
|
||||||
|
self._active = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if self._spinner:
|
||||||
|
self._spinner.start()
|
||||||
|
self._active = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
self._active = False
|
||||||
|
if self._spinner:
|
||||||
|
self._spinner.stop()
|
||||||
|
return False
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def pause(self):
|
||||||
|
"""Temporarily stop spinner while printing progress."""
|
||||||
|
if self._spinner and self._active:
|
||||||
|
self._spinner.stop()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if self._spinner and self._active:
|
||||||
|
self._spinner.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_cli_progress_line(text: str, thinking: _ThinkingSpinner | None) -> None:
|
||||||
|
"""Print a CLI progress line, pausing the spinner if needed."""
|
||||||
|
with thinking.pause() if thinking else nullcontext():
|
||||||
|
console.print(f" [dim]↳ {text}[/dim]")
|
||||||
|
|
||||||
|
|
||||||
|
async def _print_interactive_progress_line(text: str, thinking: _ThinkingSpinner | None) -> None:
|
||||||
|
"""Print an interactive progress line, pausing the spinner if needed."""
|
||||||
|
with thinking.pause() if thinking else nullcontext():
|
||||||
|
await _print_interactive_line(text)
|
||||||
|
|
||||||
|
|
||||||
def _is_exit_command(command: str) -> bool:
|
def _is_exit_command(command: str) -> bool:
|
||||||
"""Return True when input should end interactive chat."""
|
"""Return True when input should end interactive chat."""
|
||||||
return command.lower() in EXIT_COMMANDS
|
return command.lower() in EXIT_COMMANDS
|
||||||
@@ -377,7 +423,7 @@ def gateway(
|
|||||||
_print_deprecated_memory_window_notice(config)
|
_print_deprecated_memory_window_notice(config)
|
||||||
port = port if port is not None else config.gateway.port
|
port = port if port is not None else config.gateway.port
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
console.print(f"{__logo__} Starting nanobot gateway version {__version__} on port {port}...")
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
@@ -593,13 +639,8 @@ def agent(
|
|||||||
channels_config=config.channels,
|
channels_config=config.channels,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Show spinner when logs are off (no output to miss); skip when logs are on
|
# Shared reference for progress callbacks
|
||||||
def _thinking_ctx():
|
_thinking: _ThinkingSpinner | None = None
|
||||||
if logs:
|
|
||||||
from contextlib import nullcontext
|
|
||||||
return nullcontext()
|
|
||||||
# Animated spinner is safe to use with prompt_toolkit input handling
|
|
||||||
return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots")
|
|
||||||
|
|
||||||
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
|
async def _cli_progress(content: str, *, tool_hint: bool = False) -> None:
|
||||||
ch = agent_loop.channels_config
|
ch = agent_loop.channels_config
|
||||||
@@ -607,13 +648,16 @@ def agent(
|
|||||||
return
|
return
|
||||||
if ch and not tool_hint and not ch.send_progress:
|
if ch and not tool_hint and not ch.send_progress:
|
||||||
return
|
return
|
||||||
console.print(f" [dim]↳ {content}[/dim]")
|
_print_cli_progress_line(content, _thinking)
|
||||||
|
|
||||||
if message:
|
if message:
|
||||||
# Single message mode — direct call, no bus needed
|
# Single message mode — direct call, no bus needed
|
||||||
async def run_once():
|
async def run_once():
|
||||||
with _thinking_ctx():
|
nonlocal _thinking
|
||||||
|
_thinking = _ThinkingSpinner(enabled=not logs)
|
||||||
|
with _thinking:
|
||||||
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
|
response = await agent_loop.process_direct(message, session_id, on_progress=_cli_progress)
|
||||||
|
_thinking = None
|
||||||
_print_agent_response(response, render_markdown=markdown)
|
_print_agent_response(response, render_markdown=markdown)
|
||||||
await agent_loop.close_mcp()
|
await agent_loop.close_mcp()
|
||||||
|
|
||||||
@@ -663,7 +707,7 @@ def agent(
|
|||||||
elif ch and not is_tool_hint and not ch.send_progress:
|
elif ch and not is_tool_hint and not ch.send_progress:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
await _print_interactive_line(msg.content)
|
await _print_interactive_progress_line(msg.content, _thinking)
|
||||||
|
|
||||||
elif not turn_done.is_set():
|
elif not turn_done.is_set():
|
||||||
if msg.content:
|
if msg.content:
|
||||||
@@ -703,8 +747,11 @@ def agent(
|
|||||||
content=user_input,
|
content=user_input,
|
||||||
))
|
))
|
||||||
|
|
||||||
with _thinking_ctx():
|
nonlocal _thinking
|
||||||
|
_thinking = _ThinkingSpinner(enabled=not logs)
|
||||||
|
with _thinking:
|
||||||
await turn_done.wait()
|
await turn_done.wait()
|
||||||
|
_thinking = None
|
||||||
|
|
||||||
if turn_response:
|
if turn_response:
|
||||||
_print_agent_response(turn_response[0], render_markdown=markdown)
|
_print_agent_response(turn_response[0], render_markdown=markdown)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nanobot-ai"
|
name = "nanobot-ai"
|
||||||
version = "0.1.4.post4"
|
version = "0.1.4.post5"
|
||||||
description = "A lightweight personal AI assistant framework"
|
description = "A lightweight personal AI assistant framework"
|
||||||
|
readme = { file = "README.md", content-type = "text/markdown" }
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
@@ -57,3 +57,57 @@ def test_init_prompt_session_creates_session():
|
|||||||
_, kwargs = MockSession.call_args
|
_, kwargs = MockSession.call_args
|
||||||
assert kwargs["multiline"] is False
|
assert kwargs["multiline"] is False
|
||||||
assert kwargs["enable_open_in_editor"] is False
|
assert kwargs["enable_open_in_editor"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_thinking_spinner_pause_stops_and_restarts():
|
||||||
|
"""Pause should stop the active spinner and restart it afterward."""
|
||||||
|
spinner = MagicMock()
|
||||||
|
|
||||||
|
with patch.object(commands.console, "status", return_value=spinner):
|
||||||
|
thinking = commands._ThinkingSpinner(enabled=True)
|
||||||
|
with thinking:
|
||||||
|
with thinking.pause():
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert spinner.method_calls == [
|
||||||
|
call.start(),
|
||||||
|
call.stop(),
|
||||||
|
call.start(),
|
||||||
|
call.stop(),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_print_cli_progress_line_pauses_spinner_before_printing():
|
||||||
|
"""CLI progress output should pause spinner to avoid garbled lines."""
|
||||||
|
order: list[str] = []
|
||||||
|
spinner = MagicMock()
|
||||||
|
spinner.start.side_effect = lambda: order.append("start")
|
||||||
|
spinner.stop.side_effect = lambda: order.append("stop")
|
||||||
|
|
||||||
|
with patch.object(commands.console, "status", return_value=spinner), \
|
||||||
|
patch.object(commands.console, "print", side_effect=lambda *_args, **_kwargs: order.append("print")):
|
||||||
|
thinking = commands._ThinkingSpinner(enabled=True)
|
||||||
|
with thinking:
|
||||||
|
commands._print_cli_progress_line("tool running", thinking)
|
||||||
|
|
||||||
|
assert order == ["start", "stop", "print", "start", "stop"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_print_interactive_progress_line_pauses_spinner_before_printing():
|
||||||
|
"""Interactive progress output should also pause spinner cleanly."""
|
||||||
|
order: list[str] = []
|
||||||
|
spinner = MagicMock()
|
||||||
|
spinner.start.side_effect = lambda: order.append("start")
|
||||||
|
spinner.stop.side_effect = lambda: order.append("stop")
|
||||||
|
|
||||||
|
async def fake_print(_text: str) -> None:
|
||||||
|
order.append("print")
|
||||||
|
|
||||||
|
with patch.object(commands.console, "status", return_value=spinner), \
|
||||||
|
patch("nanobot.cli.commands._print_interactive_line", side_effect=fake_print):
|
||||||
|
thinking = commands._ThinkingSpinner(enabled=True)
|
||||||
|
with thinking:
|
||||||
|
await commands._print_interactive_progress_line("tool running", thinking)
|
||||||
|
|
||||||
|
assert order == ["start", "stop", "print", "start", "stop"]
|
||||||
|
|||||||
Reference in New Issue
Block a user