Compare commits

...
Author SHA1 Message Date
Mohamed Elkholyandchengyongru adcd3feb40 style: fix import sorting (ruff I001) 2026-04-19 15:32:18 +08:00
Mohamed Elkholyandchengyongru 0fe7148e6e style: move loguru import to module top level
Addresses reviewer suggestion to keep imports conventional.
2026-04-19 15:32:18 +08:00
Mohamed Elkholyandchengyongru 1ced8d4420 fix(providers): add circuit breaker for Responses API fallback
When the Responses API fails repeatedly (3 consecutive compatibility
errors), skip it and fall back directly to Chat Completions.  Unlike a
permanent disable, the circuit re-probes after 5 minutes so recovery
is automatic when the API comes back.  Success resets the counter.

Keyed per (model, reasoning_effort) so a failure with one model does
not affect others.
2026-04-19 15:32:18 +08:00
chengyongruandchengyongru 9b9e0964a2 test: add unit tests for configurable consolidation_ratio
Cover ratio propagation, schema validation, and consolidation
behavior with different ratio values (0.1, 0.5, 0.9).
2026-04-18 23:23:05 +08:00
Subalandchengyongru 1f4a6225c8 feat: make consolidation ratio configurable 2026-04-18 23:23:05 +08:00
Cheng Yongruandchengyongru 9bd29a8f4d fix(memory): fall back to raw_archive on LLM error response
When chat_with_retry returns an error response (finish_reason='error')
instead of raising an exception, archive() previously treated the error
message as a valid summary and wrote it to history.jsonl, while the
original session data was already cleared by /new — causing irreversible
data loss.

Fix: check finish_reason after the LLM call and raise RuntimeError on
error responses, which naturally falls through to the existing raw_archive
fallback. This preserves the original messages in history.jsonl instead
of losing them.

Fixes #3244
2026-04-17 17:51:30 +08:00
chengyongru 80bfcf4473 Merge branch 'main' into nightly 2026-04-17 14:22:54 +08:00
Mohamed Elkholyandchengyongru 8a34677881 fix(transcription): honor api_base for OpenAI transcription provider
Complete the symmetry left by #3214: ChannelManager._resolve_transcription_base
already resolves providers.openai.api_base, but BaseChannel.transcribe_audio
instantiated OpenAITranscriptionProvider without forwarding it, and the provider
__init__ did not accept the parameter. Self-hosted OpenAI-compatible Whisper
endpoints (LiteLLM, vLLM, etc.) configured via config.json were therefore
ignored for the OpenAI backend.

- OpenAITranscriptionProvider.__init__ now accepts api_base with env fallback
  (OPENAI_TRANSCRIPTION_BASE_URL) matching the Groq pattern.
- BaseChannel.transcribe_audio forwards self.transcription_api_base to OpenAI.
- Tests mirror the existing Groq coverage: manager propagation for provider
  "openai", BaseChannel-to-provider argument passing, and provider default vs
  override for api_url.

Fully backward-compatible: when api_base is None and the env var is unset,
the default https://api.openai.com/v1/audio/transcriptions is used.

Refs #3213, follow-up to #3214.
2026-04-17 11:21:05 +08:00
Xubin RenandXubin Ren 90ec11af4c test(channels): cover groq transcription api base propagation 2026-04-16 21:27:56 +08:00
flobo3andXubin Ren ca81b142b0 fix: pass apiBase from config to GroqTranscriptionProvider 2026-04-16 21:27:56 +08:00
3280a195af perf(tools): cache ToolRegistry.get_definitions() between mutations
get_definitions() sorts tools on every LLM iteration for prompt cache
stability.  Cache the sorted result and invalidate on register/unregister
so the sort only runs when the tool set actually changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:59:26 +08:00
chengyongruandchengyongru 43bd8aac8d fix(msteams): harden availability check and migrate docs to README
- Check both jwt and cryptography in MSTEAMS_AVAILABLE guard so
  partial installs fail early with a clear message instead of at runtime
- Add aclose() to test FakeHttpClient so stop() won't crash
- Move MSTEAMS.md into README.md following the same details/summary
  pattern used by every other channel
- Note in README that validateInboundAuth defaults to false
2026-04-16 11:08:53 +08:00
chengyongruandchengyongru b48f497f8d fix(msteams): add auth warning and restore unrelated pyproject change
Warn when validate_inbound_auth is disabled (default) so operators are
aware the webhook accepts unverified requests.  Restore pymupdf to the
dev optional-dependencies group — its removal in the original PR was
unrelated to the Teams channel feature.
2026-04-16 11:08:53 +08:00
T3chC0wb0yandchengyongru 2f3a37cf8e style(msteams): hoist time import 2026-04-16 11:08:53 +08:00
T3chC0wb0yandchengyongru 7545b58a00 refactor(msteams): remove business references 2026-04-16 11:08:53 +08:00
T3chC0wb0yandchengyongru 626903dd47 refactor(msteams): remove FWDIOC references 2026-04-16 11:08:53 +08:00
T3chC0wb0yandchengyongru e625e47a0a refactor(msteams): remove obsolete restart notify config 2026-04-16 11:08:53 +08:00
Bob Johnsonandchengyongru af7fa5bdf9 fix(msteams): remove hardcoded quote test fallback 2026-04-16 11:08:53 +08:00
chengyongruandchengyongru 2259eb7f2a fix(msteams): remove optional deps from dev extras and gate tests
PyJWT and cryptography are optional msteams deps; they should not be
bundled into the generic dev install.  Tests now skip the entire file
when the deps are missing, following the dingtalk pattern.
2026-04-16 11:08:53 +08:00
Bob Johnsonandchengyongru 8925482f93 Fix MSTeams PR review follow-ups 2026-04-16 11:08:53 +08:00
T3chC0wb0yandchengyongru c2c2351ee2 Add Microsoft Teams channel on current nightly base 2026-04-16 11:08:53 +08:00
chengyongruandchengyongru 92be47247e feat(agent): add SelfTool for runtime self-inspection and configuration
Add a built-in tool that lets the agent inspect and modify its own
runtime state (model, iterations, context window, etc.).

Key features:
- inspect: view current config, usage stats, and subagent status
- modify: adjust parameters at runtime (protected by type/range validation)
- Subagent observability: inspect running subagent tasks (phase,
  iteration, tool events, errors) — subagents are no longer a black box
- Watchdog corrects out-of-bounds values on each iteration
- Enabled by default in read-only mode (self_modify: false)
- All changes are in-memory only; restart restores defaults
- Comprehensive test suite (90 tests)

Includes a self-awareness skill (always-on) with progressive disclosure:
SKILL.md for core rules, references/examples.md for detailed scenarios.
2026-04-16 00:37:34 +08:00
Jiajun Xieandchengyongru 76683dd18a fix(cron): respect deliver flag before message tool check
When deliver: false is set in cron job payload, suppress all output even
when agent calls message tool during the turn.

Closes #3115
2026-04-15 17:42:36 +08:00
chengyongruandchengyongru dec26396ed fix(feishu): remove resuming to avoid 10-min streaming card timeout
Feishu streaming cards auto-close after 10 minutes from creation,
regardless of update activity. With resuming enabled, a single card
lives across multiple tool-call rounds and can exceed this limit,
causing the final response to be silently lost.

Remove the _resuming logic from send_delta so each tool-call round
gets its own short-lived streaming card (well under 10 min). Add a
fallback that sends a regular interactive card when the final
streaming update fails.
2026-04-14 17:01:26 +08:00
chengyongru 4c684540c5 Merge remote-tracking branch 'origin/main' into nightly 2026-04-14 00:37:21 +08:00
chengyongruandchengyongru b3288fbc87 fix(log): only log auto-compact when messages are actually archived 2026-04-13 16:52:47 +08:00
chengyongruandchengyongru b311759e87 fix(log): remove noisy no-op logs from auto-compact
Remove two debug log lines that fire on every idle channel check:
- "scheduling archival" (logged before knowing if there's work)
- "skipping, no un-consolidated messages" (the common no-op path)

The meaningful "archived" info log (only on real work) is preserved.
2026-04-13 16:09:42 +08:00
chengyongruandchengyongru 89ea2375fd fix(provider): recover trailing assistant message as user to prevent empty request
When a subagent result is injected with current_role="assistant",
_enforce_role_alternation drops the trailing assistant message, leaving
only the system prompt. Providers like Zhipu/GLM reject such requests
with error 1214 ("messages parameter invalid"). Now the last popped
assistant message is recovered as a user message when no user/tool
messages remain.
2026-04-13 12:01:45 +08:00
chengyongruandchengyongru 62bd54ac4a fix(agent): skip auto-compact for sessions with active agent tasks
Prevent proactive compaction from archiving sessions that have an
in-flight agent task, avoiding mid-turn context truncation when a
task runs longer than the idle TTL.
2026-04-13 12:01:29 +08:00
10 changed files with 408 additions and 24 deletions
+51 -20
View File
@@ -120,26 +120,57 @@
## Table of Contents
- [News](#-news)
- [Key Features](#key-features-of-nanobot)
- [Architecture](#-architecture)
- [Features](#-features)
- [Install](#-install)
- [Quick Start](#-quick-start)
- [Chat Apps](#-chat-apps)
- [Agent Social Network](#-agent-social-network)
- [Configuration](#-configuration)
- [Multiple Instances](#-multiple-instances)
- [Memory](#-memory)
- [CLI Reference](#-cli-reference)
- [In-Chat Commands](#-in-chat-commands)
- [Python SDK](#-python-sdk)
- [OpenAI-Compatible API](#-openai-compatible-api)
- [Docker](#-docker)
- [Linux Service](#-linux-service)
- [Project Structure](#-project-structure)
- [Contribute & Roadmap](#-contribute--roadmap)
- [Star History](#-star-history)
- [📢 News](#-news)
- [Key Features of nanobot:](#key-features-of-nanobot)
- [🏗️ Architecture](#-architecture)
- [Table of Contents](#table-of-contents)
- [✨ Features](#-features)
- [📦 Install](#-install)
- [Update to latest version](#update-to-latest-version)
- [🚀 Quick Start](#-quick-start)
- [💬 Chat Apps](#-chat-apps)
- [🌐 Agent Social Network](#-agent-social-network)
- [⚙️ Configuration](#-configuration)
- [Environment Variables for Secrets](#environment-variables-for-secrets)
- [Providers](#providers)
- [Channel Settings](#channel-settings)
- [Retry Behavior](#retry-behavior)
- [Web Search](#web-search)
- [`tools.web.search`](#toolswebsearch)
- [MCP (Model Context Protocol)](#mcp-model-context-protocol)
- [Security](#security)
- [Auto Compact](#auto-compact)
- [Timezone](#timezone)
- [Unified Session](#unified-session)
- [Disabled Skills](#disabled-skills)
- [🧩 Multiple Instances](#-multiple-instances)
- [Quick Start](#quick-start)
- [Path Resolution](#path-resolution)
- [How It Works](#how-it-works)
- [Minimal Setup](#minimal-setup)
- [Common Use Cases](#common-use-cases)
- [Notes](#notes)
- [🧠 Memory](#-memory)
- [💻 CLI Reference](#-cli-reference)
- [💬 In-Chat Commands](#-in-chat-commands)
- [🐍 Python SDK](#-python-sdk)
- [🔌 OpenAI-Compatible API](#-openai-compatible-api)
- [Behavior](#behavior)
- [Endpoints](#endpoints)
- [curl](#curl)
- [File Upload (JSON base64)](#file-upload-json-base64)
- [File Upload (multipart/form-data)](#file-upload-multipartform-data)
- [Python (`requests`)](#python-requests)
- [Python (`openai`)](#python-openai)
- [🐳 Docker](#-docker)
- [Docker Compose](#docker-compose)
- [Docker](#docker)
- [🐧 Linux Service](#-linux-service)
- [📁 Project Structure](#-project-structure)
- [🤝 Contribute \& Roadmap](#-contribute--roadmap)
- [Branching Strategy](#branching-strategy)
- [Contributors](#contributors)
- [⭐ Star History](#-star-history)
## ✨ Features
+2
View File
@@ -157,6 +157,7 @@ class AgentLoop:
channels_config: ChannelsConfig | None = None,
timezone: str | None = None,
session_ttl_minutes: int = 0,
consolidation_ratio: float = 0.5,
hooks: list[AgentHook] | None = None,
unified_session: bool = False,
disabled_skills: list[str] | None = None,
@@ -236,6 +237,7 @@ class AgentLoop:
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
max_completion_tokens=provider.generation.max_tokens,
consolidation_ratio=consolidation_ratio,
)
self.auto_compact = AutoCompact(
sessions=self.sessions,
+5 -1
View File
@@ -361,6 +361,7 @@ class Consolidator:
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
max_completion_tokens: int = 4096,
consolidation_ratio: float = 0.5,
):
self.store = store
self.provider = provider
@@ -368,6 +369,7 @@ class Consolidator:
self.sessions = sessions
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = max_completion_tokens
self.consolidation_ratio = consolidation_ratio
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
@@ -457,6 +459,8 @@ class Consolidator:
tools=None,
tool_choice=None,
)
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]"
self.store.append_history(summary)
return summary
@@ -477,7 +481,7 @@ class Consolidator:
lock = self.get_lock(session.key)
async with lock:
budget = self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
target = budget // 2
target = int(budget * self.consolidation_ratio)
try:
estimated, source = self.estimate_session_prompt_tokens(session)
except Exception:
+3
View File
@@ -593,6 +593,7 @@ def serve(
unified_session=runtime_config.agents.defaults.unified_session,
disabled_skills=runtime_config.agents.defaults.disabled_skills,
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
tools_config=runtime_config.tools,
)
@@ -688,6 +689,7 @@ def gateway(
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
consolidation_ratio=config.agents.defaults.consolidation_ratio,
tools_config=config.tools,
)
@@ -967,6 +969,7 @@ def agent(
unified_session=config.agents.defaults.unified_session,
disabled_skills=config.agents.defaults.disabled_skills,
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
consolidation_ratio=config.agents.defaults.consolidation_ratio,
tools_config=config.tools,
)
restart_notice = consume_restart_notice_from_env()
+7
View File
@@ -89,6 +89,13 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes",
) # Auto-compact idle threshold in minutes (0 = disabled)
consolidation_ratio: float = Field(
default=0.5,
ge=0.1,
le=0.95,
validation_alias=AliasChoices("consolidationRatio"),
serialization_alias="consolidationRatio",
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
dream: DreamConfig = Field(default_factory=DreamConfig)
+1
View File
@@ -84,6 +84,7 @@ class Nanobot:
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
consolidation_ratio=defaults.consolidation_ratio,
tools_config=config.tools,
)
return cls(loop)
+49 -3
View File
@@ -9,11 +9,13 @@ import importlib.util
import os
import secrets
import string
import time
import uuid
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
import json_repair
from loguru import logger
if os.environ.get("LANGFUSE_SECRET_KEY") and importlib.util.find_spec("langfuse"):
from langfuse.openai import AsyncOpenAI
@@ -143,6 +145,10 @@ def _uses_openrouter_attribution(spec: "ProviderSpec | None", api_base: str | No
return bool(api_base and "openrouter" in api_base.lower())
_RESPONSES_FAILURE_THRESHOLD = 3
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
def _is_direct_openai_base(api_base: str | None) -> bool:
"""Return True for direct OpenAI endpoints, not generic OpenAI-compatible gateways."""
if not api_base:
@@ -189,6 +195,11 @@ class OpenAICompatProvider(LLMProvider):
max_retries=0,
)
# Responses API circuit breaker: skip after repeated failures,
# probe again after _RESPONSES_PROBE_INTERVAL_S seconds.
self._responses_failures: dict[str, int] = {}
self._responses_tripped_at: dict[str, float] = {}
def _setup_env(self, api_key: str, api_base: str | None) -> None:
"""Set environment variables based on provider spec."""
spec = self._spec
@@ -414,9 +425,39 @@ class OpenAICompatProvider(LLMProvider):
return False
model_name = (model or self.default_model).lower()
wants = False
if reasoning_effort and reasoning_effort.lower() != "none":
return True
return any(token in model_name for token in ("gpt-5", "o1", "o3", "o4"))
wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True
if not wants:
return False
# Circuit breaker: skip after repeated failures, probe periodically.
key = f"{model_name}:{reasoning_effort or ''}"
failures = self._responses_failures.get(key, 0)
if failures >= _RESPONSES_FAILURE_THRESHOLD:
tripped = self._responses_tripped_at.get(key, 0.0)
if (time.monotonic() - tripped) < _RESPONSES_PROBE_INTERVAL_S:
return False
# Half-open: allow one probe attempt
return True
def _record_responses_failure(self, model: str | None, reasoning_effort: str | None) -> None:
key = f"{(model or self.default_model).lower()}:{reasoning_effort or ''}"
count = self._responses_failures.get(key, 0) + 1
self._responses_failures[key] = count
if count >= _RESPONSES_FAILURE_THRESHOLD:
self._responses_tripped_at[key] = time.monotonic()
logger.warning(
"Responses API circuit open for {} — falling back to Chat Completions",
key,
)
def _record_responses_success(self, model: str | None, reasoning_effort: str | None) -> None:
key = f"{(model or self.default_model).lower()}:{reasoning_effort or ''}"
self._responses_failures.pop(key, None)
self._responses_tripped_at.pop(key, None)
@staticmethod
def _should_fallback_from_responses_error(e: Exception) -> bool:
@@ -915,10 +956,13 @@ class OpenAICompatProvider(LLMProvider):
messages, tools, model, max_tokens, temperature,
reasoning_effort, tool_choice,
)
return parse_response_output(await self._client.responses.create(**body))
result = parse_response_output(await self._client.responses.create(**body))
self._record_responses_success(model, reasoning_effort)
return result
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
@@ -965,6 +1009,7 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(),
on_content_delta,
)
self._record_responses_success(model, reasoning_effort)
return LLMResponse(
content=content or None,
tool_calls=tool_calls,
@@ -975,6 +1020,7 @@ class OpenAICompatProvider(LLMProvider):
except Exception as responses_error:
if not self._should_fallback_from_responses_error(responses_error):
raise
self._record_responses_failure(model, reasoning_effort)
kwargs = self._build_kwargs(
messages, tools, model, max_tokens, temperature,
+179
View File
@@ -0,0 +1,179 @@
"""Tests for the configurable consolidation_ratio feature."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
import nanobot.agent.memory as memory_module
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
def _make_loop(
tmp_path,
*,
estimated_tokens: int = 0,
context_window_tokens: int = 200,
consolidation_ratio: float = 0.5,
) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings(max_tokens=0)
provider.estimate_prompt_tokens.return_value = (estimated_tokens, "test-counter")
_response = LLMResponse(content="ok", tool_calls=[])
provider.chat_with_retry = AsyncMock(return_value=_response)
provider.chat_stream_with_retry = AsyncMock(return_value=_response)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
context_window_tokens=context_window_tokens,
consolidation_ratio=consolidation_ratio,
)
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator._SAFETY_BUFFER = 0
return loop
@pytest.mark.asyncio
async def test_default_ratio_uses_half_budget(tmp_path, monkeypatch) -> None:
"""With ratio=0.5 (default), target should be half of budget."""
loop = _make_loop(tmp_path, context_window_tokens=200, consolidation_ratio=0.5)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
]
loop.sessions.save(session)
# budget = 200 - 0 (max_tokens) - 0 (safety_buffer) = 200
# target = int(200 * 0.5) = 100
# estimated must be >= budget to trigger consolidation
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (250, "test")
return (90, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# 250 >= 200 (budget, triggers) → 250 > 100 (target) → archive → 90 < 100, stops.
assert loop.consolidator.archive.await_count == 1
@pytest.mark.asyncio
async def test_low_ratio_aggressively_consolidates(tmp_path, monkeypatch) -> None:
"""With ratio=0.1, target is only 10% of budget — more rounds of archiving."""
loop = _make_loop(tmp_path, context_window_tokens=1000, consolidation_ratio=0.1)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
# Interleave user/assistant so pick_consolidation_boundary can find boundaries
session.messages = []
for i in range(10):
session.messages.append({"role": "user", "content": f"u{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
session.messages.append({"role": "assistant", "content": f"a{i}", "timestamp": f"2026-01-01T00:00:{i:02d}"})
loop.sessions.save(session)
# budget = 1000, target = int(1000 * 0.1) = 100
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (1200, "test")
if call_count[0] == 2:
return (800, "test")
if call_count[0] == 3:
return (400, "test")
return (50, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# With low ratio, more rounds needed to reach target; at least 2 rounds
assert loop.consolidator.archive.await_count >= 2
@pytest.mark.asyncio
async def test_high_ratio_preserves_more_history(tmp_path, monkeypatch) -> None:
"""With ratio=0.9, target is 90% of budget — consolidation stops sooner."""
loop = _make_loop(tmp_path, context_window_tokens=200, consolidation_ratio=0.9)
loop.consolidator.archive = AsyncMock(return_value=True) # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
{"role": "assistant", "content": "a2", "timestamp": "2026-01-01T00:00:03"},
{"role": "user", "content": "u3", "timestamp": "2026-01-01T00:00:04"},
{"role": "assistant", "content": "a3", "timestamp": "2026-01-01T00:00:05"},
{"role": "user", "content": "u4", "timestamp": "2026-01-01T00:00:06"},
]
loop.sessions.save(session)
# budget = 200, target = int(200 * 0.9) = 180
call_count = [0]
def mock_estimate(_session):
call_count[0] += 1
if call_count[0] == 1:
return (300, "test")
return (175, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 100)
await loop.consolidator.maybe_consolidate_by_tokens(session)
# 300 >= 200 (triggers) → 300 > 180 → archive → 175 < 180 → stop
assert loop.consolidator.archive.await_count == 1
@pytest.mark.asyncio
async def test_ratio_propagated_from_config_schema() -> None:
"""Verify consolidation_ratio is parsed from config with camelCase alias."""
from nanobot.config.schema import AgentDefaults
# Default
defaults = AgentDefaults()
assert defaults.consolidation_ratio == 0.5
# camelCase alias
defaults = AgentDefaults.model_validate({"consolidationRatio": 0.3})
assert defaults.consolidation_ratio == 0.3
# Serialization uses alias
dumped = defaults.model_dump(by_alias=True)
assert dumped["consolidationRatio"] == 0.3
@pytest.mark.asyncio
async def test_ratio_validation_rejects_out_of_range() -> None:
"""Invalid ratio values should be rejected by validation."""
from pydantic import ValidationError
from nanobot.config.schema import AgentDefaults
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=0.05)
with pytest.raises(ValidationError):
AgentDefaults(consolidation_ratio=1.0)
+40
View File
@@ -65,6 +65,46 @@ class TestConsolidatorSummarize:
assert result is None
class TestConsolidatorArchiveErrorHandling:
"""archive() must fall back to raw_archive when the LLM returns an error
response (finish_reason == 'error'), e.g. overloaded / quota exceeded.
See https://github.com/HKUDS/nanobot/issues/3244
"""
async def test_archive_falls_back_on_error_finish_reason(self, consolidator, mock_provider, store):
"""LLM returning finish_reason='error' should trigger raw_archive, not write error text."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="Error: {'type': 'error', 'error': {'type': 'overloaded_error', 'message': 'overloaded_error (529)'}}",
finish_reason="error",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done, fixed the race condition."},
]
result = await consolidator.archive(messages)
assert result is None
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" in entries[0]["content"]
assert "Error:" not in entries[0]["content"]
async def test_archive_preserves_summary_on_success(self, consolidator, mock_provider, store):
"""Normal LLM response should still produce a proper summary entry."""
mock_provider.chat_with_retry.return_value = MagicMock(
content="User fixed a bug in the auth module.",
finish_reason="stop",
)
messages = [
{"role": "user", "content": "fix the auth bug"},
{"role": "assistant", "content": "Done."},
]
result = await consolidator.archive(messages)
assert result == "User fixed a bug in the auth module."
entries = store.read_unprocessed_history(since_cursor=0)
assert len(entries) == 1
assert "[RAW]" not in entries[0]["content"]
class TestConsolidatorTokenBudget:
async def test_prompt_below_threshold_does_not_consolidate(self, consolidator):
"""No consolidation when tokens are within budget."""
@@ -0,0 +1,71 @@
"""Tests for Responses API circuit breaker in OpenAICompatProvider."""
import time
import pytest
from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
_RESPONSES_FAILURE_THRESHOLD,
_RESPONSES_PROBE_INTERVAL_S,
)
@pytest.fixture()
def provider():
"""A direct-OpenAI provider with Responses API support."""
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._effective_base = "https://api.openai.com/v1"
p._responses_failures = {}
p._responses_tripped_at = {}
return p
def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_circuit_opens_after_threshold(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
def test_circuit_does_not_affect_other_models(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("o4-mini", None) is True
def test_success_resets_circuit(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
provider._record_responses_success("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is True
def test_probe_after_interval(provider, monkeypatch):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
# Fast-forward past the probe interval
key = "gpt-5:"
provider._responses_tripped_at[key] = time.monotonic() - _RESPONSES_PROBE_INTERVAL_S - 1
assert provider._should_use_responses_api("gpt-5", None) is True
def test_below_threshold_still_allows(provider):
provider._record_responses_failure("gpt-5", None)
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is True
def test_reasoning_effort_keyed_separately(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("o3", "high")
assert provider._should_use_responses_api("o3", "high") is False
assert provider._should_use_responses_api("o3", "low") is True