Compare commits

..
Author SHA1 Message Date
chengyongruandchengyongru 08c5ce95f2 feat(feishu): per-message session for group top-level messages
Align with deer-flow: group top-level messages (no root_id) now get
their own session keyed by message_id instead of sharing a single
group-wide session. Topic replies continue to share session via
root_id.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru d5122f6df8 chore(test): remove stale reaction_id from test metadata
The production code no longer reads reaction_id from metadata, so
remove the leftover key from the test_no_removal_when_message_id_missing
test case.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 1b231eb69f fix(test): update reaction cleanup test for _reaction_ids dict
The stream-end reaction cleanup now reads from _reaction_ids instead
of metadata, so pre-populate the dict in the test instead of passing
reaction_id via metadata.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 91d5f14fbd fix(feishu): use message_id as reply target and fix keyword-only arg
Align reply targeting with deer-flow: always reply to the inbound
message_id (not root_id). The Feishu Reply API keeps responses in
the same topic automatically when the target message is inside a topic.

Also fix run_in_executor calls that passed reply_in_thread as a
positional arg to a keyword-only parameter, and route standalone
tool hints through the reply API for group chats.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru 3ece7256d1 perf(feishu): make reaction non-blocking to speed up inbound dispatch
Reaction emoji is now added as a fire-and-forget background task
instead of blocking the inbound message pipeline. This removes
one API round-trip from the critical path before the agent starts
processing.
2026-04-26 03:18:13 +08:00
chengyongruandchengyongru a0e97e360e feat(feishu): add reply_in_thread for visual topic grouping
When reply_to_message config is enabled, the bot's first reply now
uses reply_in_thread=True to create a visual topic/thread in the
Feishu client. Subsequent chunks fall back to regular create.

The reply_to_message default remains False for backward compatibility.
Failed replies still fall back to regular send — messages are never
silently dropped.
2026-04-26 03:18:13 +08:00
934372d90b feat(feishu): add thread-scoped session isolation for group chats
Thread replies (messages with root_id != message_id) in group chats
now get their own session key: feishu:{chat_id}:{root_id}. This
means each Feishu thread has an independent conversation context.

Top-level group messages and all private chat messages keep the
default session key (no override), consistent with Telegram and
Slack channel behavior.

Co-authored-by: shenchengtsi <228445050+shenchengtsi@users.noreply.github.com>
2026-04-26 03:18:13 +08:00
T3chC0wb0yandchengyongru 9eff9a70bb fix(msteams): normalize nbsp in inbound text 2026-04-25 15:26:27 +08:00
T3chC0wb0yandchengyongru 5c2c1bb9ef fix(msteams): prune bad notify refs 2026-04-25 15:26:27 +08:00
T3chC0wb0yandchengyongru d40ce81a3d fix(msteams): send threaded replies via replyToId 2026-04-25 15:26:27 +08:00
chengyongruandchengyongru 8a646d9aec fix(agent): cap recent history section in system prompt
Truncate the "Recent History" section injected by build_system_prompt()
to 32K chars. Without this, many accumulated history.jsonl entries could
still bloat the system prompt even with per-entry truncation in place.
2026-04-24 01:53:31 +08:00
chengyongruandchengyongru 93bcb0a649 fix(agent): prevent history.jsonl bloat from raw_archive and stuck consolidation
Root cause: when consolidation LLM fails, raw_archive() dumped full message
content (~1MB) into history.jsonl with no size limit. Since build_system_prompt()
injects history.jsonl into every system prompt, all subsequent LLM calls exceeded
the 200K context window with error 1261.

Additionally, _cap_consolidation_boundary's 60-message cap caused consolidation
to get stuck on sessions with long tool chains (200+ iterations), triggering
the raw_archive fallback in the first place.

Three-layer fix:
- Remove _cap_consolidation_boundary: let pick_consolidation_boundary drive
  chunk sizing based solely on token budget
- Truncate archive() input: use tiktoken to cap formatted text to the model's
  input token budget before sending to consolidation LLM
- Truncate raw_archive() output: cap history.jsonl entries at 16K chars
2026-04-24 01:41:01 +08:00
chengyongru da0ebc64fb fix(agent): prevent duplicate responses when sub-agents complete concurrently
When the main agent spawns multiple sub-agents, each completion
independently triggered a new _dispatch, causing 3-4 user-visible
responses instead of a single comprehensive report.

- Extend _drain_pending to block-wait on pending_queue when sub-agents
  are still running, keeping the runner loop alive for in-order injection
- Pass pending_queue in the system message path so subsequent sub-agent
  results can still be injected mid-turn via a new dispatch
2026-04-22 18:20:36 +08:00
chengyongru 9bf7f3b420 Merge remote-tracking branch 'origin/main' into nightly 2026-04-22 13:29:10 +08:00
hussein1362andXubin Ren 0932189860 fix: handle Windows PermissionError on directory fsync
On Windows, opening a directory with O_RDONLY raises PermissionError.
Wrap the directory fsync in a try/except PermissionError — NTFS journals
metadata synchronously so the directory sync is unnecessary there.

Also adjust test assertions to expect 1 fsync call (file only) on
Windows vs 2 (file + directory) on POSIX.
2026-04-22 13:19:53 +08:00
hussein1362andXubin Ren 512bf59b3c fix(session): fsync sessions on graceful shutdown to prevent data loss
On filesystems with write-back caching (rclone VFS, NFS, FUSE mounts)
the OS page cache may buffer recent session writes. If the process is
killed before the cache flushes, the most recent conversation turns are
silently lost — causing the agent to "forget" recent context and
respond to stale history on the next startup.

Changes:

- session/manager.py: add fsync=True option to save() that flushes the
  file and its parent directory to durable storage. Add flush_all() that
  re-saves every cached session with fsync. Default save() behavior is
  unchanged (no fsync) to avoid performance regression in normal
  operation.

- cli/commands.py: call agent.sessions.flush_all() in the gateway
  shutdown finally block, after stopping heartbeat/cron/channels.

- tests/session/test_session_fsync.py: 8 tests covering fsync flag
  behavior, flush_all with empty/multiple/errored sessions, and
  data survival across simulated process restart.

- tests/cli/test_commands.py: add sessions attribute to _FakeAgentLoop
  so the gateway health endpoint test passes with the new shutdown
  flush.
2026-04-22 13:19:53 +08:00
Xubin RenandXubin Ren ef8bbab7b3 test(cli): lock _render_interactive_ansi force_terminal to isatty
Made-with: Cursor
2026-04-22 13:12:29 +08:00
wood3nandXubin Ren 2e419f9ba2 fix(cli): respect sys.stdout.isatty() in commands.py 2026-04-22 13:12:29 +08:00
Xubin RenandXubin Ren 88c619901e review(providers): tighten comments in reasoning_effort normalize path
Made-with: Cursor
2026-04-22 12:49:55 +08:00
hlgandXubin Ren 28c42628b0 fix: normalize DashScope reasoning_effort (minimal vs minimum)
DashScope rejects the OpenAI-style value "minimal" with
`'reasoning_effort.effort' must be one of: 'none', 'minimum', 'low',
'medium', 'high', 'xhigh'`, but nanobot was passing the string through
verbatim. Users who tried the documented "minimal" to disable thinking
got a 400; users who tried the DashScope-native "minimum" to work
around it got `enable_thinking=True` because the internal comparison
was a hard string match on "minimal".

Introduce a semantic/wire split in `_build_kwargs`:

- `semantic_effort` is the internal canonical form (OpenAI vocabulary).
  "minimum" on the way in is normalized to "minimal" here so both
  spellings share one meaning.
- `wire_effort` is what we actually serialize. For DashScope with
  semantic_effort == "minimal" we translate to "minimum" on the way
  out; other providers are unchanged.
- `thinking_enabled` and the Kimi thinking branch now compare on
  `semantic_effort`, so either user spelling correctly disables
  provider-side thinking.

Tests:

- Strengthen `test_dashscope_thinking_disabled_for_minimal` to assert
  the wire value is "minimum" in addition to the extra_body signal;
  the original version only checked extra_body and let the
  invalid-value bug slip through.
- Add `test_dashscope_thinking_disabled_for_minimum_alias` so a user
  who read the DashScope docs and configured "minimum" still gets
  thinking off.
- Add `test_non_dashscope_minimal_not_retranslated` to pin down that
  the DashScope-specific translation does not leak to OpenAI et al.
2026-04-22 12:49:55 +08:00
chengyongruandXubin Ren f6a417e77d fix(transcription): harden language parameter validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
2026-04-22 12:41:32 +08:00
kandXubin Ren 123d69bfb7 fix: allow specifying transcription language 2026-04-22 12:41:32 +08:00
flobo3andXubin Ren 1826ab44fa feat(transcription): add language parameter for Groq Whisper STT 2026-04-22 12:41:32 +08:00
chengyongruandchengyongru a4a197fea5 fix(transcription): harden language parameter validation and tests
- Add ISO-639 pattern validation (2-3 lowercase letters) to schema
- Normalize empty language to None in provider constructors
- Extract shared httpx mock stubs, parameterize provider tests
- Add test for language=None omitting field from multipart body
- Add test for Pydantic pattern validation rejecting invalid codes
2026-04-22 11:02:07 +08:00
kandchengyongru bc3d734df5 fix: allow specifying transcription language 2026-04-22 11:02:07 +08:00
flobo3andchengyongru 1835f94d8e feat(transcription): add language parameter for Groq Whisper STT 2026-04-22 11:02:07 +08:00
Xubin Ren f5b8ee9f78 docs: update v0.1.5.post2 release news 2026-04-21 17:50:54 +00:00
chengyongru c51b653154 fix(retry): recognize ZhiPu 1302 rate-limit error for retry
ZhiPu API returns code 1302 with Chinese text "速率限制" instead of
standard HTTP 429 + "rate limit", causing the retry engine to treat
it as non-transient and fail immediately.
2026-04-21 17:39:51 +08:00
chengyongruandchengyongru 51cb260f05 test(tools): add basic regression tests for ContextVar routing context 2026-04-21 11:25:57 +08:00
jr_blue_551andchengyongru e4fa58ef45 agent: use ContextVar for tool routing context 2026-04-21 11:25:57 +08:00
chengyongru f2848b9b94 Merge remote-tracking branch 'origin/main' into nightly 2026-04-20 23:38:08 +08:00
chengyongru e49b56525b Merge remote-tracking branch 'origin/main' into nightly 2026-04-20 16:43:39 +08:00
chengyongruandchengyongru 3454efcd98 fix(telegram): address code review issues from cherry-pick merge
- Fix critical plain-text fallback that was sending raw HTML tags to
  users: keep raw markdown available for the fallback path
- Extract TELEGRAM_HTML_MAX_LEN (4096) constant to replace hardcoded
  magic number and document the difference from TELEGRAM_MAX_MESSAGE_LEN
- Add fallback to _send_text for extra HTML chunks when HTML parse fails
- Add missing @pytest.mark.asyncio decorator on
  test_send_delta_stream_end_html_expansion_does_not_overflow
2026-04-20 16:30:55 +08:00
c7057cb3bf fix(telegram): split oversized stream buffer mid-flight
Cherry-picked from #3311 (stutiredboy). Streaming edits called
edit_message_text(text=buf.text) without chunking, so once accumulated
deltas crossed Telegram's 4096-char limit an ongoing stream would fail
with BadRequest.

Extracts _flush_stream_overflow helper that edits the first chunk in
place, sends any middle chunks, and re-anchors the buffer to a new
message for the tail so subsequent deltas keep streaming.

Co-Authored-By: stutiredboy <stutiredboy@users.noreply.github.com>
2026-04-20 16:30:55 +08:00
himax12andchengyongru 8301a3a741 fix(telegram): convert markdown to HTML before splitting to avoid message length overflow
Cherry-picked from #3316 (himax12). When streaming completes in send_delta(),
the code was splitting raw markdown text by 4000, then converting to HTML.
The markdown-to-HTML conversion adds 10-33% characters, which could push
the result over Telegram's 4096 character limit.

The fix converts markdown to HTML first, then splits by 4096 (actual Telegram
limit), ensuring the edited message always fits.

Fixes #3315
2026-04-20 16:30:55 +08:00
jhkim43andchengyongru 1826bfd05a feat(telegram): change to mid-stream split per review feedback(#2967 PR) 2026-04-20 16:30:55 +08:00
chengyongruandchengyongru 197ecb02ca refactor(email): use _remember_processed_uid in SPF/DKIM reject paths
Replaces inline dedup logic with the existing helper to match the
style of _is_self_address and other reject branches, and to keep the
_processed_uids eviction logic in one place.
2026-04-20 14:56:13 +08:00
flobo3andchengyongru 74d314d3ef fix: deduplicate SPF/DKIM-rejected emails to stop log spam 2026-04-20 14:56:13 +08:00
chengyongru 375b1f0328 fix(webui): sync code block theme with dark mode toggle instantly
- Replace one-time DOM read with MutationObserver on <html> class
- Remove hardcoded #0a0a0a background, let oneDark/oneLight own it
- Add light-mode header/copy-button colors (bg-zinc-100 for light)
- Bump font size from 13px to 14px, line-height from 1.55 to 1.6
- Add subtle border to distinguish code block edges
2026-04-20 00:17:22 +08:00
chengyongru a7caee1186 style(webui): improve typography with Apple-inspired font stack and CJK support
- Add explicit CJK fonts (PingFang SC, Noto Sans SC, Microsoft YaHei) and
  programmer fonts (JetBrains Mono, Fira Code, Cascadia Code) to Tailwind config
- Bump prose base size from prose-sm (14px) to prose-lg (18px) for sharper CJK rendering
- Unify user/assistant message font size at 18px with CJK-aware line-height (1.8)
- Replace pure black/white foreground with Apple-style warm grays (#1d1d1f / #f5f5f7)
- Override Tailwind Typography colors to use design tokens for consistency
- Add negative letter-spacing on headings for tighter, more polished look
2026-04-20 00:03:38 +08:00
25 changed files with 1282 additions and 114 deletions
+1
View File
@@ -23,6 +23,7 @@
## 📢 News ## 📢 News
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish. - **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair. - **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing. - **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
+3 -1
View File
@@ -45,7 +45,7 @@ IMAP_PASSWORD=your-password-here
## Providers ## Providers
> [!TIP] > [!TIP]
> - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead — the API key is picked from the matching provider config. > - **Voice transcription**: Voice messages (Telegram, WhatsApp) are automatically transcribed using Whisper. By default Groq is used (free tier). Set `"transcriptionProvider": "openai"` under `channels` to use OpenAI Whisper instead, and optionally set `"transcriptionLanguage": "en"` (or another ISO-639-1 code) for more accurate transcription. The API key is picked from the matching provider config.
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link) > - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
> - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`. > - **MiniMax thinking mode**: Use `providers.minimaxAnthropic` when you want `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
@@ -440,6 +440,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
"sendToolHints": false, "sendToolHints": false,
"sendMaxRetries": 3, "sendMaxRetries": 3,
"transcriptionProvider": "groq", "transcriptionProvider": "groq",
"transcriptionLanguage": null,
"telegram": { ... } "telegram": { ... }
} }
} }
@@ -451,6 +452,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
| `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. | | `transcriptionProvider` | `"groq"` | Voice transcription backend: `"groq"` (free tier, default) or `"openai"`. API key is auto-resolved from the matching provider config. |
| `transcriptionLanguage` | `null` | Optional ISO-639-1 language hint for audio transcription, e.g. `"en"`, `"ko"`, `"ja"`. |
### Retry Behavior ### Retry Behavior
+6 -3
View File
@@ -9,7 +9,7 @@ from typing import Any
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime, truncate_text
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
@@ -19,6 +19,7 @@ class ContextBuilder:
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" _RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]"
_MAX_RECENT_HISTORY = 50 _MAX_RECENT_HISTORY = 50
_MAX_HISTORY_CHARS = 32_000 # hard cap on recent history section size
_RUNTIME_CONTEXT_END = "[/Runtime Context]" _RUNTIME_CONTEXT_END = "[/Runtime Context]"
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
@@ -56,9 +57,11 @@ class ContextBuilder:
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor()) entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
if entries: if entries:
capped = entries[-self._MAX_RECENT_HISTORY:] capped = entries[-self._MAX_RECENT_HISTORY:]
parts.append("# Recent History\n\n" + "\n".join( history_text = "\n".join(
f"- [{e['timestamp']}] {e['content']}" for e in capped f"- [{e['timestamp']}] {e['content']}" for e in capped
)) )
history_text = truncate_text(history_text, self._MAX_HISTORY_CHARS)
parts.append("# Recent History\n\n" + history_text)
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
+41 -8
View File
@@ -423,15 +423,18 @@ class AgentLoop:
self._set_runtime_checkpoint(session, payload) self._set_runtime_checkpoint(session, payload)
async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]: async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[str, Any]]:
"""Non-blocking drain of follow-up messages from the pending queue.""" """Drain follow-up messages from the pending queue.
When no messages are immediately available but sub-agents
spawned in this dispatch are still running, blocks until at
least one result arrives (or timeout). This keeps the runner
loop alive so subsequent sub-agent completions are consumed
in-order rather than dispatched separately.
"""
if pending_queue is None: if pending_queue is None:
return [] return []
items: list[dict[str, Any]] = []
while len(items) < limit: def _to_user_message(pending_msg: InboundMessage) -> dict[str, Any]:
try:
pending_msg = pending_queue.get_nowait()
except asyncio.QueueEmpty:
break
content = pending_msg.content content = pending_msg.content
media = pending_msg.media if pending_msg.media else None media = pending_msg.media if pending_msg.media else None
if media: if media:
@@ -447,7 +450,36 @@ class AgentLoop:
merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}" merged: str | list[dict[str, Any]] = f"{runtime_ctx}\n\n{user_content}"
else: else:
merged = [{"type": "text", "text": runtime_ctx}] + user_content merged = [{"type": "text", "text": runtime_ctx}] + user_content
items.append({"role": "user", "content": merged}) return {"role": "user", "content": merged}
items: list[dict[str, Any]] = []
while len(items) < limit:
try:
items.append(_to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
# Block if nothing drained but sub-agents spawned in this dispatch
# are still running. Keeps the runner loop alive so subsequent
# completions are injected in-order rather than dispatched separately.
if (not items
and session is not None
and self.subagents.get_running_count_by_session(session.key) > 0):
try:
msg = await asyncio.wait_for(pending_queue.get(), timeout=300)
except asyncio.TimeoutError:
logger.warning(
"Timeout waiting for sub-agent completion in session {}",
session.key,
)
return items
items.append(_to_user_message(msg))
while len(items) < limit:
try:
items.append(_to_user_message(pending_queue.get_nowait()))
except asyncio.QueueEmpty:
break
return items return items
result = await self.runner.run(AgentRunSpec( result = await self.runner.run(AgentRunSpec(
@@ -744,6 +776,7 @@ class AgentLoop:
final_content, _, all_msgs, _, _ = await self._run_agent_loop( final_content, _, all_msgs, _, _ = await self._run_agent_loop(
messages, session=session, channel=channel, chat_id=chat_id, messages, session=session, channel=channel, chat_id=chat_id,
message_id=msg.metadata.get("message_id"), message_id=msg.metadata.get("message_id"),
pending_queue=pending_queue,
) )
self._save_turn(session, all_msgs, 1 + len(history)) self._save_turn(session, all_msgs, 1 + len(history))
self._clear_runtime_checkpoint(session) self._clear_runtime_checkpoint(session)
+30 -29
View File
@@ -6,6 +6,7 @@ import asyncio
import json import json
import re import re
import weakref import weakref
import tiktoken
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator from typing import TYPE_CHECKING, Any, Callable, Iterator
@@ -13,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Callable, Iterator
from loguru import logger from loguru import logger
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain, strip_think, truncate_text
from nanobot.agent.runner import AgentRunSpec, AgentRunner from nanobot.agent.runner import AgentRunSpec, AgentRunner
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@@ -373,11 +374,13 @@ class MemoryStore:
) )
return "\n".join(lines) return "\n".join(lines)
def raw_archive(self, messages: list[dict]) -> None: def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization.""" """Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
formatted = truncate_text(self._format_messages(messages), limit)
self.append_history( self.append_history(
f"[RAW] {len(messages)} messages\n" f"[RAW] {len(messages)} messages\n"
f"{self._format_messages(messages)}" f"{formatted}"
) )
logger.warning( logger.warning(
"Memory consolidation degraded: raw-archived {} messages", len(messages) "Memory consolidation degraded: raw-archived {} messages", len(messages)
@@ -390,11 +393,13 @@ class MemoryStore:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_RAW_ARCHIVE_MAX_CHARS = 16_000 # cap raw_archive entries to avoid bloating history.jsonl
class Consolidator: class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl.""" """Lightweight consolidation: summarizes evicted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
_MAX_CHUNK_MESSAGES = 60 # hard cap per consolidation round
_SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift _SAFETY_BUFFER = 1024 # extra headroom for tokenizer estimation drift
@@ -447,22 +452,6 @@ class Consolidator:
return last_boundary return last_boundary
def _cap_consolidation_boundary(
self,
session: Session,
end_idx: int,
) -> int | None:
"""Clamp the chunk size without breaking the user-turn boundary."""
start = session.last_consolidated
if end_idx - start <= self._MAX_CHUNK_MESSAGES:
return end_idx
capped_end = start + self._MAX_CHUNK_MESSAGES
for idx in range(capped_end, start, -1):
if session.messages[idx].get("role") == "user":
return idx
return None
def estimate_session_prompt_tokens( def estimate_session_prompt_tokens(
self, self,
session: Session, session: Session,
@@ -486,6 +475,25 @@ class Consolidator:
self._get_tool_definitions(), self._get_tool_definitions(),
) )
@property
def _input_token_budget(self) -> int:
"""Available input token budget for consolidation LLM."""
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
def _truncate_to_token_budget(self, text: str) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget
if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
try:
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode(text)
if len(tokens) <= budget:
return text
return enc.decode(tokens[:budget]) + "\n... (truncated)"
except Exception:
return truncate_text(text, budget * 4)
async def archive(self, messages: list[dict]) -> str | None: async def archive(self, messages: list[dict]) -> str | None:
"""Summarize messages via LLM and append to history.jsonl. """Summarize messages via LLM and append to history.jsonl.
@@ -495,6 +503,7 @@ class Consolidator:
return None return None
try: try:
formatted = MemoryStore._format_messages(messages) formatted = MemoryStore._format_messages(messages)
formatted = self._truncate_to_token_budget(formatted)
response = await self.provider.chat_with_retry( response = await self.provider.chat_with_retry(
model=self.model, model=self.model,
messages=[ messages=[
@@ -536,7 +545,7 @@ class Consolidator:
lock = self.get_lock(session.key) lock = self.get_lock(session.key)
async with lock: async with lock:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER budget = self._input_token_budget
target = budget // 2 target = budget // 2
try: try:
estimated, source = self.estimate_session_prompt_tokens( estimated, source = self.estimate_session_prompt_tokens(
@@ -575,14 +584,6 @@ class Consolidator:
break break
end_idx = boundary[0] end_idx = boundary[0]
end_idx = self._cap_consolidation_boundary(session, end_idx)
if end_idx is None:
logger.debug(
"Token consolidation: no capped boundary for {} (round {})",
session.key,
round_num,
)
break
chunk = session.messages[session.last_consolidated:end_idx] chunk = session.messages[session.last_consolidated:end_idx]
if not chunk: if not chunk:
+3
View File
@@ -25,6 +25,7 @@ class BaseChannel(ABC):
transcription_provider: str = "groq" transcription_provider: str = "groq"
transcription_api_key: str = "" transcription_api_key: str = ""
transcription_api_base: str = "" transcription_api_base: str = ""
transcription_language: str | None = None
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
""" """
@@ -48,12 +49,14 @@ class BaseChannel(ABC):
provider = OpenAITranscriptionProvider( provider = OpenAITranscriptionProvider(
api_key=self.transcription_api_key, api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None, api_base=self.transcription_api_base or None,
language=self.transcription_language or None,
) )
else: else:
from nanobot.providers.transcription import GroqTranscriptionProvider from nanobot.providers.transcription import GroqTranscriptionProvider
provider = GroqTranscriptionProvider( provider = GroqTranscriptionProvider(
api_key=self.transcription_api_key, api_key=self.transcription_api_key,
api_base=self.transcription_api_base or None, api_base=self.transcription_api_base or None,
language=self.transcription_language or None,
) )
return await provider.transcribe(file_path) return await provider.transcribe(file_path)
except Exception as e: except Exception as e:
+147 -35
View File
@@ -308,6 +308,8 @@ class FeishuChannel(BaseChannel):
self._loop: asyncio.AbstractEventLoop | None = None self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {} self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
self._bot_open_id: str | None = None self._bot_open_id: str | None = None
self._background_tasks: set[asyncio.Task] = set()
self._reaction_ids: dict[str, str] = {} # message_id → reaction_id
@staticmethod @staticmethod
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any: def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
@@ -549,8 +551,11 @@ class FeishuChannel(BaseChannel):
return None return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None: async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
""" """Add a reaction emoji to a message.
Add a reaction emoji to a message (non-blocking).
Returns the reaction_id on success, None on failure.
When called via a tracked background task, the returned reaction_id
is stored in ``_reaction_ids`` for later cleanup by ``send_delta``.
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
""" """
@@ -594,6 +599,30 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id) await loop.run_in_executor(None, self._remove_reaction_sync, message_id, reaction_id)
def _on_background_task_done(self, task: asyncio.Task) -> None:
"""Callback: remove from tracking set and log unhandled exceptions."""
self._background_tasks.discard(task)
if task.cancelled():
return
try:
task.result()
except Exception as exc:
logger.warning("Background task failed: {}", exc)
def _on_reaction_added(self, message_id: str, task: asyncio.Task) -> None:
"""Callback: store reaction_id after background add-reaction completes."""
if task.cancelled():
return
try:
reaction_id = task.result()
if reaction_id:
self._reaction_ids[message_id] = reaction_id
except Exception:
pass # already logged by _on_background_task_done
# Trim cache to prevent unbounded growth
if len(self._reaction_ids) > 500:
self._reaction_ids.pop(next(iter(self._reaction_ids)))
# Regex to match markdown tables (header + separator + data rows) # Regex to match markdown tables (header + separator + data rows)
_TABLE_RE = re.compile( _TABLE_RE = re.compile(
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)", r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
@@ -1101,17 +1130,23 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e) logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
return None return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool: def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str, *, reply_in_thread: bool = False) -> bool:
"""Reply to an existing Feishu message using the Reply API (synchronous).""" """Reply to an existing Feishu message using the Reply API (synchronous).
Args:
reply_in_thread: If True, reply as a thread/topic message
in the Feishu client.
"""
from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody from lark_oapi.api.im.v1 import ReplyMessageRequest, ReplyMessageRequestBody
try: try:
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
if reply_in_thread:
body_builder = body_builder.reply_in_thread(True)
request = ( request = (
ReplyMessageRequest.builder() ReplyMessageRequest.builder()
.message_id(parent_message_id) .message_id(parent_message_id)
.request_body( .request_body(body_builder.build())
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
)
.build() .build()
) )
response = self._client.im.v1.message.reply(request) response = self._client.im.v1.message.reply(request)
@@ -1166,8 +1201,19 @@ class FeishuChannel(BaseChannel):
logger.error("Error sending Feishu {} message: {}", msg_type, e) logger.error("Error sending Feishu {} message: {}", msg_type, e)
return None return None
def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None: def _create_streaming_card_sync(
"""Create a CardKit streaming card, send it to chat, return card_id.""" self,
receive_id_type: str,
chat_id: str,
reply_message_id: str | None = None,
) -> str | None:
"""Create a CardKit streaming card, send it to chat, return card_id.
When *reply_message_id* is provided the card is delivered via the
reply API (with reply_in_thread=True) so it lands inside the
originating thread / topic. Otherwise the plain create-message
API is used.
"""
from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody from lark_oapi.api.cardkit.v1 import CreateCardRequest, CreateCardRequestBody
card_json = { card_json = {
@@ -1196,13 +1242,19 @@ class FeishuChannel(BaseChannel):
return None return None
card_id = getattr(response.data, "card_id", None) card_id = getattr(response.data, "card_id", None)
if card_id: if card_id:
message_id = self._send_message_sync( card_content = json.dumps(
receive_id_type, {"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
chat_id,
"interactive",
json.dumps({"type": "card", "data": {"card_id": card_id}}),
) )
if message_id: if reply_message_id:
sent = self._reply_message_sync(
reply_message_id, "interactive", card_content,
reply_in_thread=True,
)
else:
sent = self._send_message_sync(
receive_id_type, chat_id, "interactive", card_content,
) is not None
if sent:
return card_id return card_id
logger.warning( logger.warning(
"Created streaming card {} but failed to send it to {}", card_id, chat_id "Created streaming card {} but failed to send it to {}", card_id, chat_id
@@ -1292,7 +1344,7 @@ class FeishuChannel(BaseChannel):
_stream_end: Finalize the streaming card. _stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only). _tool_hint: Delta is a formatted tool hint (for display only).
message_id: Original message id (used with _stream_end for reaction cleanup). message_id: Original message id (used with _stream_end for reaction cleanup).
reaction_id: Reaction id to remove on stream end. chat_type: "group" or "p2p" — controls reply-in-thread for streaming cards.
""" """
if not self._client: if not self._client:
return return
@@ -1302,10 +1354,13 @@ class FeishuChannel(BaseChannel):
# --- stream end: final update or fallback --- # --- stream end: final update or fallback ---
if meta.get("_stream_end"): if meta.get("_stream_end"):
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")): message_id = meta.get("message_id")
await self._remove_reaction(message_id, reaction_id) if message_id:
reaction_id = self._reaction_ids.pop(message_id, None)
if reaction_id:
await self._remove_reaction(message_id, reaction_id)
# Add completion emoji if configured # Add completion emoji if configured
if self.config.done_emoji and message_id: if self.config.done_emoji:
await self._add_reaction(message_id, self.config.done_emoji) await self._add_reaction(message_id, self.config.done_emoji)
buf = self._stream_bufs.pop(chat_id, None) buf = self._stream_bufs.pop(chat_id, None)
@@ -1343,9 +1398,22 @@ class FeishuChannel(BaseChannel):
{"config": {"wide_screen_mode": True}, "elements": chunk}, {"config": {"wide_screen_mode": True}, "elements": chunk},
ensure_ascii=False, ensure_ascii=False,
) )
await loop.run_in_executor( # Fallback: reply via the Reply API for group chats.
None, self._send_message_sync, rid_type, chat_id, "interactive", card # Target message_id — the Feishu API keeps the reply in
) # the same topic automatically.
_f_msg = meta.get("message_id")
fallback_msg_id = _f_msg if meta.get("chat_type", "group") == "group" else None
if fallback_msg_id:
await loop.run_in_executor(
None, lambda: self._reply_message_sync(
fallback_msg_id, "interactive", card,
reply_in_thread=True,
),
)
else:
await loop.run_in_executor(
None, self._send_message_sync, rid_type, chat_id, "interactive", card
)
return return
# --- accumulate delta --- # --- accumulate delta ---
@@ -1359,8 +1427,16 @@ class FeishuChannel(BaseChannel):
now = time.monotonic() now = time.monotonic()
if buf.card_id is None: if buf.card_id is None:
# Send the streaming card as a reply for group chats so it
# lands inside the originating topic/thread. Always target
# message_id (the actual inbound message) — the Feishu Reply
# API keeps the response in the same topic automatically.
is_group = meta.get("chat_type", "group") == "group"
reply_msg_id = meta.get("message_id") if is_group else None
card_id = await loop.run_in_executor( card_id = await loop.run_in_executor(
None, self._create_streaming_card_sync, rid_type, chat_id None,
self._create_streaming_card_sync,
rid_type, chat_id, reply_msg_id,
) )
if card_id: if card_id:
buf.card_id = card_id buf.card_id = card_id
@@ -1404,37 +1480,59 @@ class FeishuChannel(BaseChannel):
return return
# No active streaming card — send as a regular # No active streaming card — send as a regular
# interactive card with the same 🔧 prefix style. # interactive card with the same 🔧 prefix style.
# Use reply API for group chats so the hint stays in topic.
card = json.dumps( card = json.dumps(
{"config": {"wide_screen_mode": True}, "elements": [ {"config": {"wide_screen_mode": True}, "elements": [
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)}, {"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
]}, ]},
ensure_ascii=False, ensure_ascii=False,
) )
await loop.run_in_executor( _th_msg_id = msg.metadata.get("message_id")
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card _th_chat_type = msg.metadata.get("chat_type", "group")
) if _th_msg_id and _th_chat_type == "group":
await loop.run_in_executor(
None, lambda: self._reply_message_sync(
_th_msg_id, "interactive", card,
reply_in_thread=True,
),
)
else:
await loop.run_in_executor(
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
)
return return
# Determine whether the first message should quote the user's message. # Determine whether the first message should quote the user's message.
# Only the very first send (media or text) in this call uses reply; subsequent # Only the very first send (media or text) in this call uses reply; subsequent
# chunks/media fall back to plain create to avoid redundant quote bubbles. # chunks/media fall back to plain create to avoid redundant quote bubbles.
# Always target message_id — the Feishu Reply API keeps replies in the
# same topic automatically when the target message is inside a topic.
reply_message_id: str | None = None reply_message_id: str | None = None
_msg_id = msg.metadata.get("message_id")
if self.config.reply_to_message and not msg.metadata.get("_progress", False): if self.config.reply_to_message and not msg.metadata.get("_progress", False):
reply_message_id = msg.metadata.get("message_id") or None reply_message_id = _msg_id
# For topic group messages, always reply to keep context in thread # For topic group messages, always reply to keep context in thread
elif msg.metadata.get("thread_id"): elif msg.metadata.get("thread_id"):
reply_message_id = ( reply_message_id = _msg_id
msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
)
first_send = True # tracks whether the reply has already been used first_send = True # tracks whether the reply has already been used
def _do_send(m_type: str, content: str) -> None: def _do_send(m_type: str, content: str) -> None:
"""Send via reply (first message) or create (subsequent).""" """Send via reply (first message) or create (subsequent).
For group chats the reply API always uses reply_in_thread=True.
The Feishu API automatically keeps replies inside existing
topics — reply_in_thread only creates a *new* topic when the
target message is a plain (non-topic) message.
"""
nonlocal first_send nonlocal first_send
if reply_message_id and first_send: if reply_message_id and first_send:
first_send = False first_send = False
ok = self._reply_message_sync(reply_message_id, m_type, content) chat_type = msg.metadata.get("chat_type", "group")
ok = self._reply_message_sync(
reply_message_id, m_type, content,
reply_in_thread=chat_type == "group",
)
if ok: if ok:
return return
# Fall back to regular send if reply fails # Fall back to regular send if reply fails
@@ -1543,8 +1641,13 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: skipping group message (not mentioned)") logger.debug("Feishu: skipping group message (not mentioned)")
return return
# Add reaction # Add reaction (non-blocking — tracked background task)
reaction_id = await self._add_reaction(message_id, self.config.react_emoji) task = asyncio.create_task(
self._add_reaction(message_id, self.config.react_emoji)
)
self._background_tasks.add(task)
task.add_done_callback(self._on_background_task_done)
task.add_done_callback(lambda t: self._on_reaction_added(message_id, t))
# Parse content # Parse content
content_parts = [] content_parts = []
@@ -1624,6 +1727,15 @@ class FeishuChannel(BaseChannel):
if not content and not media_paths: if not content and not media_paths:
return return
# Build topic-scoped session key for conversation isolation.
# Group chat: each topic gets its own session via root_id (replies
# inside a topic) or message_id (top-level messages start a new topic).
# Private chat: no override — same behavior as Telegram/Slack.
if chat_type == "group":
session_key = f"feishu:{chat_id}:{root_id or message_id}"
else:
session_key = None
# Forward to message bus # Forward to message bus
reply_to = chat_id if chat_type == "group" else sender_id reply_to = chat_id if chat_type == "group" else sender_id
await self._handle_message( await self._handle_message(
@@ -1633,13 +1745,13 @@ class FeishuChannel(BaseChannel):
media=media_paths, media=media_paths,
metadata={ metadata={
"message_id": message_id, "message_id": message_id,
"reaction_id": reaction_id,
"chat_type": chat_type, "chat_type": chat_type,
"msg_type": msg_type, "msg_type": msg_type,
"parent_id": parent_id, "parent_id": parent_id,
"root_id": root_id, "root_id": root_id,
"thread_id": thread_id, "thread_id": thread_id,
}, },
session_key=session_key,
) )
except Exception as e: except Exception as e:
+2
View File
@@ -63,6 +63,7 @@ class ChannelManager:
transcription_provider = self.config.channels.transcription_provider transcription_provider = self.config.channels.transcription_provider
transcription_key = self._resolve_transcription_key(transcription_provider) transcription_key = self._resolve_transcription_key(transcription_provider)
transcription_base = self._resolve_transcription_base(transcription_provider) transcription_base = self._resolve_transcription_base(transcription_provider)
transcription_language = self.config.channels.transcription_language
for name, cls in discover_all().items(): for name, cls in discover_all().items():
section = getattr(self.config.channels, name, None) section = getattr(self.config.channels, name, None)
@@ -88,6 +89,7 @@ class ChannelManager:
channel.transcription_provider = transcription_provider channel.transcription_provider = transcription_provider
channel.transcription_api_key = transcription_key channel.transcription_api_key = transcription_key
channel.transcription_api_base = transcription_base channel.transcription_api_base = transcription_base
channel.transcription_language = transcription_language
self.channels[name] = channel self.channels[name] = channel
logger.info("{} channel enabled", cls.display_name) logger.info("{} channel enabled", cls.display_name)
except Exception as e: except Exception as e:
+37 -3
View File
@@ -70,6 +70,7 @@ class ConversationRef:
activity_id: str | None = None activity_id: str | None = None
conversation_type: str | None = None conversation_type: str | None = None
tenant_id: str | None = None tenant_id: str | None = None
updated_at: float | None = None
class MSTeamsChannel(BaseChannel): class MSTeamsChannel(BaseChannel):
@@ -220,7 +221,6 @@ class MSTeamsChannel(BaseChannel):
token = await self._get_access_token() token = await self._get_access_token()
base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities" base_url = f"{ref.service_url.rstrip('/')}/v3/conversations/{ref.conversation_id}/activities"
use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id) use_thread_reply = self.config.reply_in_thread and bool(ref.activity_id)
url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url
headers = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -233,7 +233,7 @@ class MSTeamsChannel(BaseChannel):
payload["replyToId"] = ref.activity_id payload["replyToId"] = ref.activity_id
try: try:
resp = await self._http.post(url, headers=headers, json=payload) resp = await self._http.post(base_url, headers=headers, json=payload)
resp.raise_for_status() resp.raise_for_status()
logger.info("MSTeams message sent to {}", ref.conversation_id) logger.info("MSTeams message sent to {}", ref.conversation_id)
except Exception as e: except Exception as e:
@@ -289,7 +289,9 @@ class MSTeamsChannel(BaseChannel):
activity_id=activity_id or None, activity_id=activity_id or None,
conversation_type=conversation_type or None, conversation_type=conversation_type or None,
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None, tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
updated_at=time.time(),
) )
self._save_refs() self._save_refs()
await self._handle_message( await self._handle_message(
@@ -310,10 +312,12 @@ class MSTeamsChannel(BaseChannel):
"""Extract the user-authored text from a Teams activity.""" """Extract the user-authored text from a Teams activity."""
text = str(activity.get("text") or "") text = str(activity.get("text") or "")
text = self._strip_possible_bot_mention(text) text = self._strip_possible_bot_mention(text)
text = self._normalize_html_whitespace(text)
channel_data = activity.get("channelData") or {} channel_data = activity.get("channelData") or {}
reply_to_id = str(activity.get("replyToId") or "").strip() reply_to_id = str(activity.get("replyToId") or "").strip()
normalized_preview = html.unescape(text).replace("&rsquo", "").strip() normalized_preview = html.unescape(text).replace("&rsquo", "").strip()
normalized_preview = normalized_preview.replace("\xa0", " ")
normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n") normalized_preview = normalized_preview.replace("\r\n", "\n").replace("\r", "\n")
preview_lines = [line.strip() for line in normalized_preview.split("\n")] preview_lines = [line.strip() for line in normalized_preview.split("\n")]
while preview_lines and not preview_lines[0]: while preview_lines and not preview_lines[0]:
@@ -333,9 +337,15 @@ class MSTeamsChannel(BaseChannel):
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned) cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
return cleaned.strip() return cleaned.strip()
def _normalize_html_whitespace(self, text: str) -> str:
"""Normalize common HTML whitespace/entities from Teams into plain text spacing."""
normalized = html.unescape(text).replace("&rsquo", "")
normalized = normalized.replace("\xa0", " ")
return normalized
def _normalize_teams_reply_quote(self, text: str) -> str: def _normalize_teams_reply_quote(self, text: str) -> str:
"""Normalize Teams quoted replies into a compact structured form.""" """Normalize Teams quoted replies into a compact structured form."""
cleaned = html.unescape(text).replace("&rsquo", "").strip() cleaned = self._normalize_html_whitespace(text).strip()
if not cleaned: if not cleaned:
return "" return ""
@@ -494,6 +504,14 @@ class MSTeamsChannel(BaseChannel):
def _save_refs(self) -> None: def _save_refs(self) -> None:
"""Persist conversation references.""" """Persist conversation references."""
try: try:
stale_keys = [
key
for key, ref in self._conversation_refs.items()
if self._is_stale_or_unsupported_ref(ref)
]
for key in stale_keys:
self._conversation_refs.pop(key, None)
data = { data = {
key: { key: {
"service_url": ref.service_url, "service_url": ref.service_url,
@@ -502,6 +520,7 @@ class MSTeamsChannel(BaseChannel):
"activity_id": ref.activity_id, "activity_id": ref.activity_id,
"conversation_type": ref.conversation_type, "conversation_type": ref.conversation_type,
"tenant_id": ref.tenant_id, "tenant_id": ref.tenant_id,
"updated_at": ref.updated_at,
} }
for key, ref in self._conversation_refs.items() for key, ref in self._conversation_refs.items()
} }
@@ -509,6 +528,21 @@ class MSTeamsChannel(BaseChannel):
except Exception as e: except Exception as e:
logger.warning("Failed to save MSTeams conversation refs: {}", e) logger.warning("Failed to save MSTeams conversation refs: {}", e)
def _is_stale_or_unsupported_ref(self, ref: ConversationRef) -> bool:
"""Reject unsupported refs and prune old refs."""
service_url = (ref.service_url or "").strip().lower()
conversation_type = (ref.conversation_type or "").strip().lower()
updated_at = ref.updated_at or 0.0
max_age_seconds = 30 * 24 * 60 * 60
if "webchat.botframework.com" in service_url:
return True
if conversation_type and conversation_type != "personal":
return True
if updated_at and updated_at < time.time() - max_age_seconds:
return True
return False
async def _get_access_token(self) -> str: async def _get_access_token(self) -> str:
"""Fetch an access token for Bot Framework / Azure Bot auth.""" """Fetch an access token for Bot Framework / Azure Bot auth."""
+7 -1
View File
@@ -145,7 +145,7 @@ def _make_console() -> Console:
def _render_interactive_ansi(render_fn) -> str: def _render_interactive_ansi(render_fn) -> str:
"""Render Rich output to ANSI so prompt_toolkit can print it safely.""" """Render Rich output to ANSI so prompt_toolkit can print it safely."""
ansi_console = Console( ansi_console = Console(
force_terminal=True, force_terminal=sys.stdout.isatty(),
color_system=console.color_system or "standard", color_system=console.color_system or "standard",
width=console.width, width=console.width,
) )
@@ -946,6 +946,12 @@ def _run_gateway(
cron.stop() cron.stop()
agent.stop() agent.stop()
await channels.stop_all() await channels.stop_all()
# Flush all cached sessions to durable storage before exit.
# This prevents data loss on filesystems with write-back
# caching (rclone VFS, NFS, FUSE mounts, etc.).
flushed = agent.sessions.flush_all()
if flushed:
logger.info("Shutdown: flushed {} session(s) to disk", flushed)
asyncio.run(run()) asyncio.run(run())
+1
View File
@@ -29,6 +29,7 @@ class ChannelsConfig(Base):
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…")) send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included) send_max_retries: int = Field(default=3, ge=0, le=10) # Max delivery attempts (initial send included)
transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai" transcription_provider: str = "groq" # Voice transcription backend: "groq" or "openai"
transcription_language: str | None = Field(default=None, pattern=r"^[a-z]{2,3}$") # Optional ISO-639-1 hint for audio transcription
class DreamConfig(Base): class DreamConfig(Base):
+18 -4
View File
@@ -387,14 +387,28 @@ class OpenAICompatProvider(LLMProvider):
kwargs.update(overrides) kwargs.update(overrides)
break break
if reasoning_effort: # Normalize reasoning_effort into a semantic form (OpenAI vocab)
kwargs["reasoning_effort"] = reasoning_effort # used for internal decisions, and a wire form actually sent out.
# "minimum" is accepted as a DashScope-native alias for "minimal".
semantic_effort: str | None = None
if isinstance(reasoning_effort, str):
semantic_effort = reasoning_effort.lower()
if semantic_effort == "minimum":
semantic_effort = "minimal"
wire_effort = reasoning_effort
if spec and spec.name == "dashscope" and semantic_effort == "minimal":
# DashScope accepts none/minimum/low/medium/high/xhigh; "minimal" 400s.
wire_effort = "minimum"
if wire_effort:
kwargs["reasoning_effort"] = wire_effort
# Provider-specific thinking parameters. # Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that # Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise. # the provider default is preserved otherwise.
if spec and reasoning_effort is not None: if spec and reasoning_effort is not None:
thinking_enabled = reasoning_effort.lower() != "minimal" thinking_enabled = semantic_effort != "minimal"
extra: dict[str, Any] | None = None extra: dict[str, Any] | None = None
if spec.name == "dashscope": if spec.name == "dashscope":
extra = {"enable_thinking": thinking_enabled} extra = {"enable_thinking": thinking_enabled}
@@ -415,7 +429,7 @@ class OpenAICompatProvider(LLMProvider):
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled # so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
# identically to bare names like "kimi-k2.5". # identically to bare names like "kimi-k2.5".
if reasoning_effort is not None and _is_kimi_thinking_model(model_name): if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
thinking_enabled = reasoning_effort.lower() != "minimal" thinking_enabled = semantic_effort != "minimal"
kwargs.setdefault("extra_body", {}).update( kwargs.setdefault("extra_body", {}).update(
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}} {"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
) )
+18 -2
View File
@@ -10,13 +10,19 @@ from loguru import logger
class OpenAITranscriptionProvider: class OpenAITranscriptionProvider:
"""Voice transcription provider using OpenAI's Whisper API.""" """Voice transcription provider using OpenAI's Whisper API."""
def __init__(self, api_key: str | None = None, api_base: str | None = None): def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
):
self.api_key = api_key or os.environ.get("OPENAI_API_KEY") self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
self.api_url = ( self.api_url = (
api_base api_base
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL") or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
or "https://api.openai.com/v1/audio/transcriptions" or "https://api.openai.com/v1/audio/transcriptions"
) )
self.language = language or None
async def transcribe(self, file_path: str | Path) -> str: async def transcribe(self, file_path: str | Path) -> str:
if not self.api_key: if not self.api_key:
@@ -30,6 +36,8 @@ class OpenAITranscriptionProvider:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
with open(path, "rb") as f: with open(path, "rb") as f:
files = {"file": (path.name, f), "model": (None, "whisper-1")} files = {"file": (path.name, f), "model": (None, "whisper-1")}
if self.language:
files["language"] = (None, self.language)
headers = {"Authorization": f"Bearer {self.api_key}"} headers = {"Authorization": f"Bearer {self.api_key}"}
response = await client.post( response = await client.post(
self.api_url, headers=headers, files=files, timeout=60.0, self.api_url, headers=headers, files=files, timeout=60.0,
@@ -48,9 +56,15 @@ class GroqTranscriptionProvider:
Groq offers extremely fast transcription with a generous free tier. Groq offers extremely fast transcription with a generous free tier.
""" """
def __init__(self, api_key: str | None = None, api_base: str | None = None): def __init__(
self,
api_key: str | None = None,
api_base: str | None = None,
language: str | None = None,
):
self.api_key = api_key or os.environ.get("GROQ_API_KEY") self.api_key = api_key or os.environ.get("GROQ_API_KEY")
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions" self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
self.language = language or None
async def transcribe(self, file_path: str | Path) -> str: async def transcribe(self, file_path: str | Path) -> str:
""" """
@@ -78,6 +92,8 @@ class GroqTranscriptionProvider:
"file": (path.name, f), "file": (path.name, f),
"model": (None, "whisper-large-v3"), "model": (None, "whisper-large-v3"),
} }
if self.language:
files["language"] = (None, self.language)
headers = { headers = {
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
} }
+43 -2
View File
@@ -262,8 +262,16 @@ class SessionManager:
"messages": session.messages, "messages": session.messages,
} }
def save(self, session: Session) -> None: def save(self, session: Session, *, fsync: bool = False) -> None:
"""Save a session to disk atomically.""" """Save a session to disk atomically.
When *fsync* is ``True`` the final file and its parent directory are
explicitly flushed to durable storage. This is intentionally off by
default (the OS page-cache is sufficient for normal operation) but
should be enabled during graceful shutdown so that filesystems with
write-back caching (e.g. rclone VFS, NFS, FUSE mounts) do not lose
the most recent writes.
"""
path = self._get_session_path(session.key) path = self._get_session_path(session.key)
tmp_path = path.with_suffix(".jsonl.tmp") tmp_path = path.with_suffix(".jsonl.tmp")
@@ -280,14 +288,47 @@ class SessionManager:
f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n") f.write(json.dumps(metadata_line, ensure_ascii=False) + "\n")
for msg in session.messages: for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n") f.write(json.dumps(msg, ensure_ascii=False) + "\n")
if fsync:
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path) os.replace(tmp_path, path)
if fsync:
# fsync the directory so the rename is durable.
# On Windows, opening a directory with O_RDONLY raises
# PermissionError — skip the dir sync there (NTFS
# journals metadata synchronously).
try:
fd = os.open(str(path.parent), os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
except PermissionError:
pass # Windows — directory fsync not supported
except BaseException: except BaseException:
tmp_path.unlink(missing_ok=True) tmp_path.unlink(missing_ok=True)
raise raise
self._cache[session.key] = session self._cache[session.key] = session
def flush_all(self) -> int:
"""Re-save every cached session with fsync for durable shutdown.
Returns the number of sessions flushed. Errors on individual
sessions are logged but do not prevent other sessions from being
flushed.
"""
flushed = 0
for key, session in list(self._cache.items()):
try:
self.save(session, fsync=True)
flushed += 1
except Exception:
logger.warning("Failed to flush session {}", key, exc_info=True)
return flushed
def invalidate(self, key: str) -> None: def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache.""" """Remove a session from the in-memory cache."""
self._cache.pop(key, None) self._cache.pop(key, None)
+78 -13
View File
@@ -4,7 +4,7 @@ import pytest
import asyncio import asyncio
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from nanobot.agent.memory import Consolidator, MemoryStore from nanobot.agent.memory import Consolidator, MemoryStore, _RAW_ARCHIVE_MAX_CHARS
@pytest.fixture @pytest.fixture
@@ -117,8 +117,8 @@ class TestConsolidatorTokenBudget:
await consolidator.maybe_consolidate_by_tokens(session) await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_called() consolidator.archive.assert_not_called()
async def test_chunk_cap_preserves_user_turn_boundary(self, consolidator): async def test_large_chunk_archived_without_cap(self, consolidator):
"""Chunk cap should rewind to the last user boundary within the cap.""" """Without chunk cap, the full range from pick_consolidation_boundary is archived."""
consolidator._SAFETY_BUFFER = 0 consolidator._SAFETY_BUFFER = 0
session = MagicMock() session = MagicMock()
session.last_consolidated = 0 session.last_consolidated = 0
@@ -133,19 +133,19 @@ class TestConsolidatorTokenBudget:
consolidator.estimate_session_prompt_tokens = MagicMock( consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=[(1200, "tiktoken"), (400, "tiktoken")] side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
) )
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999)) # Use real pick_consolidation_boundary — it will find boundary at idx=50
# (user message at 50, token budget met)
consolidator.archive = AsyncMock(return_value=True) consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session) await consolidator.maybe_consolidate_by_tokens(session)
archived_chunk = consolidator.archive.await_args.args[0] archived_chunk = consolidator.archive.await_args.args[0]
assert len(archived_chunk) == 50 # pick_consolidation_boundary returns (50, tokens) — user turn at idx 50
assert archived_chunk[0]["content"] == "m0" assert archived_chunk[0]["content"] == "m0"
assert archived_chunk[-1]["content"] == "m49" assert session.last_consolidated > 0
assert session.last_consolidated == 50
async def test_chunk_cap_skips_when_no_user_boundary_within_cap(self, consolidator): async def test_boundary_respected_when_no_intermediate_user_turn(self, consolidator):
"""If the cap would cut mid-turn, consolidation should skip that round.""" """When boundary points past a long tool chain, the full chunk is archived."""
consolidator._SAFETY_BUFFER = 0 consolidator._SAFETY_BUFFER = 0
session = MagicMock() session = MagicMock()
session.last_consolidated = 0 session.last_consolidated = 0
@@ -157,11 +157,76 @@ class TestConsolidatorTokenBudget:
} }
for i in range(70) for i in range(70)
] ]
consolidator.estimate_session_prompt_tokens = MagicMock(return_value=(1200, "tiktoken")) consolidator.estimate_session_prompt_tokens = MagicMock(
consolidator.pick_consolidation_boundary = MagicMock(return_value=(61, 999)) side_effect=[(1200, "tiktoken"), (400, "tiktoken")]
)
consolidator.archive = AsyncMock(return_value=True) consolidator.archive = AsyncMock(return_value=True)
await consolidator.maybe_consolidate_by_tokens(session) await consolidator.maybe_consolidate_by_tokens(session)
consolidator.archive.assert_not_awaited() consolidator.archive.assert_awaited_once()
assert session.last_consolidated == 0 # pick_consolidation_boundary finds the only boundary at idx=61
assert session.last_consolidated == 61
class TestRawArchiveTruncation:
"""raw_archive() must cap entry size to avoid bloating history.jsonl."""
def test_raw_archive_truncates_large_content(self, store):
"""Large messages should be truncated to _RAW_ARCHIVE_MAX_CHARS."""
big = "x" * 50_000
messages = [{"role": "user", "content": big}]
store.raw_archive(messages)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert len(entries[0]["content"]) < 50_000
assert "[RAW]" in entries[0]["content"]
def test_raw_archive_preserves_small_content(self, store):
"""Small messages should not be truncated."""
messages = [{"role": "user", "content": "hello"}]
store.raw_archive(messages)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "hello" in entries[0]["content"]
def test_raw_archive_custom_max_chars(self, store):
"""max_chars parameter should override default limit."""
messages = [{"role": "user", "content": "a" * 200}]
store.raw_archive(messages, max_chars=100)
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries[0]["content"]) < 200
class TestArchiveTruncation:
"""archive() must truncate formatted text before sending to consolidation LLM."""
async def test_archive_truncates_large_formatted_text(self, consolidator, mock_provider, store):
"""Large formatted text should be truncated to token budget before LLM call."""
# context_window_tokens=1000, max_completion_tokens=100, _SAFETY_BUFFER=1024
# budget = 1000 - 100 - 1024 = -124 → fallback via truncate_text(budget*4)
big_messages = [{"role": "user", "content": "x" * 100_000}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary of large input.", finish_reason="stop"
)
await consolidator.archive(big_messages)
call_args = mock_provider.chat_with_retry.call_args
user_content = call_args.kwargs["messages"][1]["content"]
# Should be significantly shorter than 100K
assert len(user_content) < 50_000
async def test_archive_truncates_with_small_token_budget(self, consolidator, mock_provider, store):
"""Small context window: truncation uses actual tokenizer count."""
consolidator.context_window_tokens = 500
big_messages = [{"role": "user", "content": "word " * 50_000}]
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary.", finish_reason="stop"
)
await consolidator.archive(big_messages)
sent_messages = mock_provider.chat_with_retry.call_args.kwargs["messages"]
user_content = sent_messages[1]["content"]
# budget = 500 - 100 - 1024 = negative, fallback char-based
# Should be truncated
assert len(user_content) < 250_000
+208 -1
View File
@@ -1,8 +1,9 @@
"""Tests for subagent tool registration and wiring.""" """Tests for subagent tool registration and wiring."""
import asyncio
import time import time
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -51,3 +52,209 @@ async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
) )
mgr.runner.run.assert_awaited_once() mgr.runner.run.assert_awaited_once()
@pytest.mark.asyncio
async def test_drain_pending_blocks_while_subagents_running(tmp_path):
"""_drain_pending should block when no messages are available but sub-agents are still running."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.subagent import SubagentManager
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
session = Session(key="test:drain-block")
injection_callback = None
# Capture the injection_callback that _run_agent_loop creates
original_run = loop.runner.run
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
# Simulate: first call to injection_callback should block because
# sub-agents are running and no messages are in the queue yet.
# We'll resolve this from a concurrent task.
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
# Register a running sub-agent in the SubagentManager for this session
async def _hang_forever():
await asyncio.Event().wait()
hang_task = asyncio.create_task(_hang_forever())
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-drain-1")
loop.subagents._running_tasks["sub-drain-1"] = hang_task
# Run _run_agent_loop — this defines the _drain_pending closure
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=session,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# Now test the callback directly
# With sub-agents running and an empty queue, it should block
drain_task = asyncio.create_task(injection_callback())
# Give it a moment to enter the blocking wait
await asyncio.sleep(0.05)
# Should still be running (blocked on pending_queue.get())
assert not drain_task.done(), "drain should block while sub-agents are running"
# Now put a message in the queue (simulating sub-agent completion)
await pending_queue.put(InboundMessage(
sender_id="subagent",
channel="test",
chat_id="c1",
content="Sub-agent result",
media=None,
metadata={},
))
# Should unblock and return results
results = await asyncio.wait_for(drain_task, timeout=2.0)
assert len(results) >= 1
assert results[0]["role"] == "user"
assert "Sub-agent result" in str(results[0]["content"])
# Cleanup
hang_task.cancel()
try:
await hang_task
except asyncio.CancelledError:
pass
@pytest.mark.asyncio
async def test_drain_pending_no_block_when_no_subagents(tmp_path):
"""_drain_pending should not block when no sub-agents are running."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue = asyncio.Queue()
injection_callback = None
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=None,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# With no sub-agents and empty queue, should return immediately
results = await asyncio.wait_for(injection_callback(), timeout=1.0)
assert results == []
@pytest.mark.asyncio
async def test_drain_pending_timeout(tmp_path):
"""_drain_pending should return empty after timeout when sub-agents hang."""
from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus
from nanobot.session.manager import Session
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
pending_queue: asyncio.Queue = asyncio.Queue()
session = Session(key="test:drain-timeout")
injection_callback = None
async def fake_runner_run(spec):
nonlocal injection_callback
injection_callback = spec.injection_callback
return SimpleNamespace(
stop_reason="done",
final_content="done",
error=None,
tool_events=[],
messages=[],
usage={},
had_injections=False,
tools_used=[],
)
loop.runner.run = AsyncMock(side_effect=fake_runner_run)
# Register a "running" sub-agent that will never complete
async def _hang_forever():
await asyncio.Event().wait()
hang_task = asyncio.create_task(_hang_forever())
loop.subagents._session_tasks.setdefault(session.key, set()).add("sub-timeout-1")
loop.subagents._running_tasks["sub-timeout-1"] = hang_task
await loop._run_agent_loop(
[{"role": "user", "content": "test"}],
session=session,
channel="test",
chat_id="c1",
pending_queue=pending_queue,
)
assert injection_callback is not None
# Patch the timeout to be very short for testing
with patch("nanobot.agent.loop.asyncio.wait_for") as mock_wait:
mock_wait.side_effect = asyncio.TimeoutError
results = await injection_callback()
assert results == []
# Cleanup
hang_task.cancel()
try:
await hang_task
except asyncio.CancelledError:
pass
+133 -6
View File
@@ -15,7 +15,6 @@ from nanobot.channels.manager import ChannelManager
from nanobot.config.schema import ChannelsConfig from nanobot.config.schema import ChannelsConfig
from nanobot.utils.restart import RestartNotice from nanobot.utils.restart import RestartNotice
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -200,8 +199,8 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
fake_config = SimpleNamespace( fake_config = SimpleNamespace(
channels=ChannelsConfig.model_validate({ channels=ChannelsConfig.model_validate({
"fakeplugin": {"enabled": True, "allowFrom": ["*"]}, "fakeplugin": {"enabled": True, "allowFrom": ["*"]},
"transcriptionLanguage": "en",
}), }),
transcription_provider="groq",
providers=SimpleNamespace( providers=SimpleNamespace(
groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"), groq=SimpleNamespace(api_key="groq-key", api_base="http://proxy.local/v1/audio/transcriptions"),
openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"), openai=SimpleNamespace(api_key="openai-key", api_base="https://api.openai.com/v1/audio/transcriptions"),
@@ -223,6 +222,7 @@ async def test_manager_propagates_groq_transcription_api_base_to_channels():
assert channel.transcription_provider == "groq" assert channel.transcription_provider == "groq"
assert channel.transcription_api_key == "groq-key" assert channel.transcription_api_key == "groq-key"
assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions" assert channel.transcription_api_base == "http://proxy.local/v1/audio/transcriptions"
assert channel.transcription_language == "en"
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -269,13 +269,15 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
channel.transcription_provider = "openai" channel.transcription_provider = "openai"
channel.transcription_api_key = "k" channel.transcription_api_key = "k"
channel.transcription_api_base = "http://override/v1/audio/transcriptions" channel.transcription_api_base = "http://override/v1/audio/transcriptions"
channel.transcription_language = "en"
captured: dict[str, object] = {} captured: dict[str, object] = {}
class _StubOpenAI: class _StubOpenAI:
def __init__(self, api_key=None, api_base=None): def __init__(self, api_key=None, api_base=None, language=None):
captured["api_key"] = api_key captured["api_key"] = api_key
captured["api_base"] = api_base captured["api_base"] = api_base
captured["language"] = language
async def transcribe(self, file_path): async def transcribe(self, file_path):
return "ok" return "ok"
@@ -286,6 +288,7 @@ async def test_base_channel_passes_api_base_to_openai_transcription_provider():
assert result == "ok" assert result == "ok"
assert captured["api_key"] == "k" assert captured["api_key"] == "k"
assert captured["api_base"] == "http://override/v1/audio/transcriptions" assert captured["api_base"] == "http://override/v1/audio/transcriptions"
assert captured["language"] == "en"
def test_openai_transcription_provider_honors_api_base_argument(): def test_openai_transcription_provider_honors_api_base_argument():
@@ -300,10 +303,114 @@ def test_openai_transcription_provider_honors_api_base_argument():
assert custom.api_url == "http://override/v1/audio/transcriptions" assert custom.api_url == "http://override/v1/audio/transcriptions"
@pytest.mark.asyncio
async def test_base_channel_passes_language_to_groq_transcription_provider():
"""BaseChannel.transcribe_audio must forward transcription_language to Groq."""
from nanobot.providers import transcription as transcription_mod
channel = _FakePlugin({"enabled": True, "allowFrom": ["*"]}, MessageBus())
channel.transcription_provider = "groq"
channel.transcription_api_key = "k"
channel.transcription_api_base = "http://override/v1/audio/transcriptions"
channel.transcription_language = "ko"
captured: dict[str, object] = {}
class _StubGroq:
def __init__(self, api_key=None, api_base=None, language=None):
captured["api_key"] = api_key
captured["api_base"] = api_base
captured["language"] = language
async def transcribe(self, file_path):
return "ok"
with patch.object(transcription_mod, "GroqTranscriptionProvider", _StubGroq):
result = await channel.transcribe_audio("/tmp/does-not-matter.wav")
assert result == "ok"
assert captured["api_key"] == "k"
assert captured["api_base"] == "http://override/v1/audio/transcriptions"
assert captured["language"] == "ko"
# ---------------------------------------------------------------------------
# Transcription provider HTTP tests
# ---------------------------------------------------------------------------
from nanobot.providers.transcription import GroqTranscriptionProvider as _GroqProvider
from nanobot.providers.transcription import OpenAITranscriptionProvider as _OpenAIProvider
class _StubResponse:
def raise_for_status(self):
return None
def json(self):
return {"text": "hello"}
def _stub_async_client(captured: dict[str, object]):
"""Return an httpx.AsyncClient stub that records POST calls into *captured*."""
class _AsyncClient:
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, url, headers=None, files=None, timeout=None):
captured["files"] = files
return _StubResponse()
return _AsyncClient()
@pytest.mark.parametrize(
"provider_cls,language",
[(_GroqProvider, "ko"), (_OpenAIProvider, "en")],
ids=["groq", "openai"],
)
@pytest.mark.asyncio
async def test_transcription_provider_includes_language(tmp_path, provider_cls, language):
"""Provider must include the 'language' field in multipart body when set."""
audio = tmp_path / "sample.wav"
audio.write_bytes(b"audio")
captured: dict[str, object] = {}
with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
provider = provider_cls(api_key="k", language=language)
result = await provider.transcribe(audio)
assert result == "hello"
assert captured["files"]["language"] == (None, language)
@pytest.mark.parametrize(
"provider_cls",
[_GroqProvider, _OpenAIProvider],
ids=["groq", "openai"],
)
@pytest.mark.asyncio
async def test_transcription_provider_omits_language_when_none(tmp_path, provider_cls):
"""When language is not set, the 'language' key must be absent from the multipart body."""
audio = tmp_path / "sample.wav"
audio.write_bytes(b"audio")
captured: dict[str, object] = {}
with patch("nanobot.providers.transcription.httpx.AsyncClient", return_value=_stub_async_client(captured)):
provider = provider_cls(api_key="k")
result = await provider.transcribe(audio)
assert result == "hello"
assert "language" not in captured["files"]
def test_channels_login_uses_discovered_plugin_class(monkeypatch): def test_channels_login_uses_discovered_plugin_class(monkeypatch):
from typer.testing import CliRunner
from nanobot.cli.commands import app from nanobot.cli.commands import app
from nanobot.config.schema import Config from nanobot.config.schema import Config
from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
seen: dict[str, object] = {} seen: dict[str, object] = {}
@@ -329,9 +436,10 @@ def test_channels_login_uses_discovered_plugin_class(monkeypatch):
def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path): def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app from nanobot.cli.commands import app
from nanobot.config.schema import Config from nanobot.config.schema import Config
from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
seen: dict[str, object] = {} seen: dict[str, object] = {}
@@ -358,9 +466,10 @@ def test_channels_login_sets_custom_config_path(monkeypatch, tmp_path):
def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path): def test_channels_status_sets_custom_config_path(monkeypatch, tmp_path):
from typer.testing import CliRunner
from nanobot.cli.commands import app from nanobot.cli.commands import app
from nanobot.config.schema import Config from nanobot.config.schema import Config
from typer.testing import CliRunner
runner = CliRunner() runner = CliRunner()
seen: dict[str, object] = {} seen: dict[str, object] = {}
@@ -455,6 +564,24 @@ def test_channels_config_send_max_retries_upper_bound():
ChannelsConfig(send_max_retries=11) ChannelsConfig(send_max_retries=11)
def test_channels_config_transcription_language_pattern():
"""transcription_language must match ISO-639 format (2-3 lowercase letters) or be None."""
from pydantic import ValidationError
# Valid values
assert ChannelsConfig(transcription_language="en").transcription_language == "en"
assert ChannelsConfig(transcription_language="kor").transcription_language == "kor"
assert ChannelsConfig(transcription_language=None).transcription_language is None
# Invalid values
with pytest.raises(ValidationError):
ChannelsConfig(transcription_language="EN") # uppercase
with pytest.raises(ValidationError):
ChannelsConfig(transcription_language="english") # full word
with pytest.raises(ValidationError):
ChannelsConfig(transcription_language="en-US") # BCP 47 tag
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# _send_with_retry # _send_with_retry
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+3 -2
View File
@@ -166,13 +166,14 @@ class TestStreamEndReactionCleanup:
ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf( ch._stream_bufs["oc_chat1"] = _FeishuStreamBuf(
text="Done", card_id="card_1", sequence=3, last_edit=0.0, text="Done", card_id="card_1", sequence=3, last_edit=0.0,
) )
ch._reaction_ids["om_001"] = "rx_42"
ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True)) ch._client.cardkit.v1.card_element.content.return_value = MagicMock(success=MagicMock(return_value=True))
ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True)) ch._client.cardkit.v1.card.settings.return_value = MagicMock(success=MagicMock(return_value=True))
ch._remove_reaction = AsyncMock() ch._remove_reaction = AsyncMock()
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "message_id": "om_001", "reaction_id": "rx_42"}, metadata={"_stream_end": True, "message_id": "om_001"},
) )
ch._remove_reaction.assert_called_once_with("om_001", "rx_42") ch._remove_reaction.assert_called_once_with("om_001", "rx_42")
@@ -189,7 +190,7 @@ class TestStreamEndReactionCleanup:
await ch.send_delta( await ch.send_delta(
"oc_chat1", "", "oc_chat1", "",
metadata={"_stream_end": True, "reaction_id": "rx_42"}, metadata={"_stream_end": True},
) )
ch._remove_reaction.assert_not_called() ch._remove_reaction.assert_not_called()
+288 -2
View File
@@ -3,7 +3,7 @@ import asyncio
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -26,13 +26,14 @@ from nanobot.channels.feishu import FeishuChannel, FeishuConfig
# Helpers # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_feishu_channel(reply_to_message: bool = False) -> FeishuChannel: def _make_feishu_channel(reply_to_message: bool = False, group_policy: str = "mention") -> FeishuChannel:
config = FeishuConfig( config = FeishuConfig(
enabled=True, enabled=True,
app_id="cli_test", app_id="cli_test",
app_secret="secret", app_secret="secret",
allow_from=["*"], allow_from=["*"],
reply_to_message=reply_to_message, reply_to_message=reply_to_message,
group_policy=group_policy,
) )
channel = FeishuChannel(config, MessageBus()) channel = FeishuChannel(config, MessageBus())
channel._client = MagicMock() channel._client = MagicMock()
@@ -443,3 +444,288 @@ async def test_on_message_no_extra_api_call_when_no_parent_id() -> None:
channel._client.im.v1.message.get.assert_not_called() channel._client.im.v1.message.get.assert_not_called()
assert len(captured) == 1 assert len(captured) == 1
# ---------------------------------------------------------------------------
# Session key derivation tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_key_group_with_root_id_is_thread_scoped() -> None:
"""Group message with root_id gets a thread-scoped session key."""
channel = _make_feishu_channel(group_policy="open")
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id="om_root123",
message_id="om_child456",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key == "feishu:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_group_no_root_id_uses_message_id() -> None:
"""Group message without root_id gets session keyed by message_id (per-message session)."""
channel = _make_feishu_channel(group_policy="open")
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id=None,
message_id="om_001",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
channel = _make_feishu_channel()
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="p2p",
content='{"text": "hello"}',
root_id=None,
message_id="om_001",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].session_key_override is None
# ---------------------------------------------------------------------------
# reply_in_thread tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_reply_uses_reply_in_thread_when_enabled() -> None:
"""When reply_to_message is True, reply includes reply_in_thread=True."""
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = reply_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001"},
))
channel._client.im.v1.message.reply.assert_called_once()
call_args = channel._client.im.v1.message.reply.call_args
request = call_args[0][0]
assert request.request_body.reply_in_thread is True
@pytest.mark.asyncio
async def test_reply_without_reply_in_thread_when_disabled() -> None:
"""When reply_to_message is False, reply does NOT use reply_in_thread."""
channel = _make_feishu_channel(reply_to_message=False)
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
))
# No message_id in metadata → no reply attempt, direct create
channel._client.im.v1.message.create.assert_called_once()
@pytest.mark.asyncio
async def test_reply_keeps_fallback_when_reply_fails() -> None:
"""Even with reply_to_message=True, fallback to create on reply failure."""
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = False
reply_resp.code = 99991400
reply_resp.msg = "rate limited"
channel._client.im.v1.message.reply.return_value = reply_resp
create_resp = MagicMock()
create_resp.success.return_value = True
channel._client.im.v1.message.create.return_value = create_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001"},
))
channel._client.im.v1.message.reply.assert_called()
channel._client.im.v1.message.create.assert_called()
@pytest.mark.asyncio
async def test_reply_no_reply_in_thread_for_p2p_chat() -> None:
"""reply_in_thread should NOT be set for p2p chats (identified by chat_type)."""
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = reply_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc", # p2p chats also use oc_ prefix
content="hello",
metadata={"message_id": "om_001", "chat_type": "p2p"},
))
channel._client.im.v1.message.reply.assert_called_once()
call_args = channel._client.im.v1.message.reply.call_args
request = call_args[0][0]
assert request.request_body.reply_in_thread is not True
@pytest.mark.asyncio
async def test_reply_uses_reply_in_thread_for_group_chat() -> None:
"""reply_in_thread should be True for group chats (identified by chat_type)."""
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = reply_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={"message_id": "om_001", "chat_type": "group"},
))
channel._client.im.v1.message.reply.assert_called_once()
call_args = channel._client.im.v1.message.reply.call_args
request = call_args[0][0]
assert request.request_body.reply_in_thread is True
@pytest.mark.asyncio
async def test_reply_targets_message_id_when_in_topic() -> None:
"""When inbound message is inside a topic (root_id != message_id),
the reply should target the inbound message_id (not root_id).
The Feishu Reply API keeps the response in the same topic
automatically when the target message is already inside a topic."""
channel = _make_feishu_channel(reply_to_message=True)
reply_resp = MagicMock()
reply_resp.success.return_value = True
channel._client.im.v1.message.reply.return_value = reply_resp
await channel.send(OutboundMessage(
channel="feishu",
chat_id="oc_abc",
content="hello",
metadata={
"message_id": "om_child456",
"chat_type": "group",
"root_id": "om_root123",
},
))
channel._client.im.v1.message.reply.assert_called_once()
call_args = channel._client.im.v1.message.reply.call_args
request = call_args[0][0]
# Should reply to the inbound message_id, not the root
assert request.message_id == "om_child456"
assert request.request_body.reply_in_thread is True
def test_on_reaction_added_stores_reaction_id() -> None:
"""_on_reaction_added stores the returned reaction_id in _reaction_ids."""
channel = _make_feishu_channel()
loop = asyncio.new_event_loop()
try:
task = loop.create_task(asyncio.sleep(0, result="reaction_abc"))
loop.run_until_complete(task)
channel._on_reaction_added("om_001", task)
finally:
loop.close()
assert channel._reaction_ids["om_001"] == "reaction_abc"
def test_on_reaction_added_skips_none_result() -> None:
"""_on_reaction_added does not store None results."""
channel = _make_feishu_channel()
loop = asyncio.new_event_loop()
try:
task = loop.create_task(asyncio.sleep(0, result=None))
loop.run_until_complete(task)
channel._on_reaction_added("om_001", task)
finally:
loop.close()
assert "om_001" not in channel._reaction_ids
def test_on_background_task_done_removes_from_set() -> None:
"""_on_background_task_done removes task from tracking set."""
channel = _make_feishu_channel()
loop = asyncio.new_event_loop()
try:
async def _fail():
raise RuntimeError("test failure")
task = loop.create_task(_fail())
channel._background_tasks.add(task)
try:
loop.run_until_complete(task)
except RuntimeError:
pass # expected
channel._on_background_task_done(task)
finally:
loop.close()
assert task not in channel._background_tasks
+19
View File
@@ -183,3 +183,22 @@ def test_make_console_force_terminal_false_when_stdout_is_not_tty():
with patch.object(sys.stdout, "isatty", return_value=False): with patch.object(sys.stdout, "isatty", return_value=False):
console = stream_mod._make_console() console = stream_mod._make_console()
assert console._force_terminal is False assert console._force_terminal is False
def test_render_interactive_ansi_force_terminal_follows_isatty():
"""Mirror of _make_console: the capture console used to produce ANSI for
prompt_toolkit must also defer to sys.stdout.isatty(), otherwise cursor
escapes and spinner frames leak into piped output (#3265, #3370)."""
import sys
captured: dict = {}
def render_fn(c):
captured["console"] = c
with patch.object(sys.stdout, "isatty", return_value=True):
commands._render_interactive_ansi(render_fn)
assert captured["console"]._force_terminal is True
with patch.object(sys.stdout, "isatty", return_value=False):
commands._render_interactive_ansi(render_fn)
assert captured["console"]._force_terminal is False
+5
View File
@@ -1288,10 +1288,15 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
async def run(self) -> None: async def run(self) -> None:
return None return None
class _FakeSessionManager:
def flush_all(self) -> int:
return 0
class _FakeAgentLoop: class _FakeAgentLoop:
def __init__(self, **_kwargs) -> None: def __init__(self, **_kwargs) -> None:
self.model = "test-model" self.model = "test-model"
self.dream = _FakeDream() self.dream = _FakeDream()
self.sessions = _FakeSessionManager()
async def run(self) -> None: async def run(self) -> None:
await asyncio.Event().wait() await asyncio.Event().wait()
+15
View File
@@ -731,10 +731,25 @@ def test_dashscope_thinking_enabled_with_reasoning_effort() -> None:
def test_dashscope_thinking_disabled_for_minimal() -> None: def test_dashscope_thinking_disabled_for_minimal() -> None:
"""'minimal' → wire 'minimum' + thinking off on DashScope."""
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal") kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimal")
assert kw["reasoning_effort"] == "minimum"
assert kw["extra_body"] == {"enable_thinking": False} assert kw["extra_body"] == {"enable_thinking": False}
def test_dashscope_thinking_disabled_for_minimum_alias() -> None:
"""Native 'minimum' spelling must also disable thinking, not enable it."""
kw = _build_kwargs_for("dashscope", "qwen3-plus", reasoning_effort="minimum")
assert kw["reasoning_effort"] == "minimum"
assert kw["extra_body"] == {"enable_thinking": False}
def test_non_dashscope_minimal_not_retranslated() -> None:
"""DashScope-specific translation must not leak to other providers."""
kw = _build_kwargs_for("openai", "gpt-5", reasoning_effort="minimal")
assert kw["reasoning_effort"] == "minimal"
def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None: def test_dashscope_no_extra_body_when_reasoning_effort_none() -> None:
kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None) kw = _build_kwargs_for("dashscope", "qwen-turbo", reasoning_effort=None)
assert "extra_body" not in kw assert "extra_body" not in kw
View File
+130
View File
@@ -0,0 +1,130 @@
"""Tests for session fsync and flush_all on graceful shutdown."""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.session.manager import SessionManager
_IS_WINDOWS = sys.platform == "win32"
@pytest.fixture
def sessions_dir(tmp_path: Path) -> Path:
d = tmp_path / "sessions"
d.mkdir()
return tmp_path
@pytest.fixture
def manager(sessions_dir: Path) -> SessionManager:
return SessionManager(workspace=sessions_dir)
class TestSaveFsync:
"""Verify that save(fsync=True) calls os.fsync."""
def test_save_without_fsync_does_not_call_fsync(self, manager: SessionManager):
session = manager.get_or_create("test:no-fsync")
session.add_message("user", "hello")
with patch("os.fsync") as mock_fsync:
manager.save(session, fsync=False)
mock_fsync.assert_not_called()
def test_save_with_fsync_calls_fsync(self, manager: SessionManager):
session = manager.get_or_create("test:with-fsync")
session.add_message("user", "hello")
with patch("os.fsync") as mock_fsync:
manager.save(session, fsync=True)
# File fsync always runs; directory fsync only on non-Windows.
expected = 1 if _IS_WINDOWS else 2
assert mock_fsync.call_count == expected
def test_save_default_no_fsync(self, manager: SessionManager):
"""Default save() should not fsync (backward compat)."""
session = manager.get_or_create("test:default")
session.add_message("user", "hello")
with patch("os.fsync") as mock_fsync:
manager.save(session)
mock_fsync.assert_not_called()
class TestFlushAll:
"""Verify flush_all re-saves all cached sessions with fsync."""
def test_flush_all_empty_cache(self, manager: SessionManager):
assert manager.flush_all() == 0
def test_flush_all_saves_cached_sessions(self, manager: SessionManager):
s1 = manager.get_or_create("test:session-1")
s1.add_message("user", "msg 1")
manager.save(s1)
s2 = manager.get_or_create("test:session-2")
s2.add_message("user", "msg 2")
manager.save(s2)
flushed = manager.flush_all()
assert flushed == 2
def test_flush_all_uses_fsync(self, manager: SessionManager):
session = manager.get_or_create("test:fsync-check")
session.add_message("user", "important")
manager.save(session)
with patch("os.fsync") as mock_fsync:
manager.flush_all()
# file fsync always; directory fsync only on non-Windows
expected = 1 if _IS_WINDOWS else 2
assert mock_fsync.call_count == expected
def test_flush_all_continues_on_error(self, manager: SessionManager):
"""One broken session should not prevent others from flushing."""
s1 = manager.get_or_create("test:good")
s1.add_message("user", "ok")
manager.save(s1)
s2 = manager.get_or_create("test:bad")
s2.add_message("user", "ok")
manager.save(s2)
original_save = manager.save
call_count = {"n": 0}
def patched_save(session, *, fsync=False):
call_count["n"] += 1
if session.key == "test:bad":
raise OSError("disk on fire")
original_save(session, fsync=fsync)
manager.save = patched_save
flushed = manager.flush_all()
# One succeeded, one failed — flush_all returns successful count
assert flushed == 1
assert call_count["n"] == 2
def test_flush_all_data_survives_reload(self, sessions_dir: Path):
"""Data flushed by flush_all should survive a fresh SessionManager load."""
mgr1 = SessionManager(workspace=sessions_dir)
session = mgr1.get_or_create("test:persist")
session.add_message("user", "remember this")
session.add_message("assistant", "noted")
mgr1.save(session)
mgr1.flush_all()
# Simulate process restart — new manager, cold cache
mgr2 = SessionManager(workspace=sessions_dir)
reloaded = mgr2.get_or_create("test:persist")
history = reloaded.get_history(max_messages=100)
assert len(history) == 2
assert history[0]["content"] == "remember this"
assert history[1]["content"] == "noted"
+46 -2
View File
@@ -1,4 +1,5 @@
import json import json
import time
import pytest import pytest
@@ -260,6 +261,17 @@ def test_sanitize_inbound_text_keeps_normal_inline_message(make_channel):
assert ch._sanitize_inbound_text(activity) == "normal inline message" assert ch._sanitize_inbound_text(activity) == "normal inline message"
def test_sanitize_inbound_text_normalizes_nbsp_entities(make_channel):
ch = make_channel()
activity = {
"text": "Hello&nbsp;from&nbsp;Teams",
"channelData": {},
}
assert ch._sanitize_inbound_text(activity) == "Hello from Teams"
def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel): def test_sanitize_inbound_text_normalizes_reply_wrapper_without_reply_metadata(make_channel):
ch = make_channel() ch = make_channel()
@@ -371,7 +383,7 @@ async def test_get_access_token_uses_configured_tenant(make_channel):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channel): async def test_send_posts_to_conversation_with_reply_to_id_when_reply_in_thread_enabled(make_channel):
ch = make_channel(replyInThread=True) ch = make_channel(replyInThread=True)
fake_http = FakeHttpClient() fake_http = FakeHttpClient()
ch._http = fake_http ch._http = fake_http
@@ -387,7 +399,7 @@ async def test_send_replies_to_activity_when_reply_in_thread_enabled(make_channe
assert len(fake_http.calls) == 1 assert len(fake_http.calls) == 1
url, kwargs = fake_http.calls[0] url, kwargs = fake_http.calls[0]
assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities/activity-1" assert url == "https://smba.trafficmanager.net/amer/v3/conversations/conv-123/activities"
assert kwargs["headers"]["Authorization"] == "Bearer tok" assert kwargs["headers"]["Authorization"] == "Bearer tok"
assert kwargs["json"]["text"] == "Reply text" assert kwargs["json"]["text"] == "Reply text"
assert kwargs["json"]["replyToId"] == "activity-1" assert kwargs["json"]["replyToId"] == "activity-1"
@@ -551,6 +563,38 @@ async def test_start_logs_install_hint_when_pyjwt_missing(make_channel, monkeypa
assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"] assert errors == ["PyJWT not installed. Run: pip install nanobot-ai[msteams]"]
def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
ch = make_channel()
now = time.time()
ch._conversation_refs = {
"teams-good": ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="teams-good",
conversation_type="personal",
updated_at=now,
),
"webchat-bad": ConversationRef(
service_url="https://webchat.botframework.com/",
conversation_id="webchat-bad",
conversation_type=None,
updated_at=now,
),
"teams-stale": ConversationRef(
service_url="https://smba.trafficmanager.net/amer/",
conversation_id="teams-stale",
conversation_type="personal",
updated_at=now - (31 * 24 * 60 * 60),
),
}
ch._save_refs()
assert set(ch._conversation_refs) == {"teams-good"}
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
assert set(saved) == {"teams-good"}
assert saved["teams-good"]["updated_at"] == pytest.approx(now)
def test_msteams_default_config_includes_restart_notify_fields(): def test_msteams_default_config_includes_restart_notify_fields():
cfg = MSTeamsChannel.default_config() cfg = MSTeamsChannel.default_config()