Merge remote-tracking branch 'origin/main' into codex/webui-segmented-transcript-store

This commit is contained in:
Xubin Ren
2026-06-10 19:11:37 +08:00
34 changed files with 1022 additions and 56 deletions
+11 -1
View File
@@ -70,6 +70,8 @@ class ContextBuilder:
session_summary: str | None = None,
workspace: Path | None = None,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
root = workspace or self.workspace
@@ -96,7 +98,11 @@ class ContextBuilder:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
if include_memory_recent_history:
entries = self.memory.read_unprocessed_history(since_cursor=self.memory.get_last_dream_cursor())
entries = self.memory.read_recent_history_for_prompt(
since_cursor=self.memory.get_last_dream_cursor(),
session_key=session_key,
unified_session=unified_session,
)
if entries:
capped = entries[-self._MAX_RECENT_HISTORY:]
history_text = "\n".join(
@@ -196,6 +202,8 @@ class ContextBuilder:
inbound_message: Any | None = None,
skip_runtime_lines: bool = False,
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
@@ -232,6 +240,8 @@ class ContextBuilder:
session_summary=session_summary,
workspace=root,
include_memory_recent_history=include_memory_recent_history,
session_key=session_key,
unified_session=unified_session,
),
},
*history,
+12 -2
View File
@@ -9,6 +9,7 @@ import time
from contextlib import AsyncExitStack, nullcontext, suppress
from dataclasses import dataclass, field
from enum import Enum, auto
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable
@@ -314,6 +315,7 @@ class AgentLoop:
get_tool_definitions=self.tools.get_definitions,
max_completion_tokens=provider.generation.max_tokens,
consolidation_ratio=consolidation_ratio,
unified_session=unified_session,
)
self.auto_compact = AutoCompact(
sessions=self.sessions,
@@ -610,6 +612,8 @@ class AgentLoop:
runtime_state=self,
inbound_message=msg,
include_memory_recent_history=include_memory_recent_history,
session_key=session.key,
unified_session=self._unified_session,
)
async def _dispatch_command_inline(
@@ -1150,6 +1154,8 @@ class AgentLoop:
runtime_state=self,
inbound_message=msg,
skip_runtime_lines=is_subagent,
session_key=key,
unified_session=self._unified_session,
)
t_wall = time.time()
final_content, _, all_msgs, stop_reason, _ = await self._run_agent_loop(
@@ -1163,7 +1169,9 @@ class AgentLoop:
latency_ms = max(0, int((wall_done - t_wall) * 1000))
self._save_turn(session, all_msgs, 1 + len(history), turn_latency_ms=latency_ms)
self._runtime_events().record_turn_latency(key, latency_ms)
session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
session.enforce_file_cap(
on_archive=partial(self.context.memory.raw_archive, session_key=key)
)
self._clear_runtime_checkpoint(session)
self.sessions.save(session)
self._schedule_background(
@@ -1487,7 +1495,9 @@ class AgentLoop:
ctx.turn_latency_ms,
)
if not ctx.ephemeral:
ctx.session.enforce_file_cap(on_archive=self.context.memory.raw_archive)
ctx.session.enforce_file_cap(
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
)
self._schedule_background(
self.consolidator.maybe_consolidate_by_tokens(
ctx.session,
+69 -9
View File
@@ -41,6 +41,8 @@ class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
_LEGACY_TIMESTAMP_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2})\]\s*")
_LEGACY_RAW_MESSAGE_RE = re.compile(
@@ -232,7 +234,13 @@ class MemoryStore:
# -- history.jsonl — append-only, JSONL format ---------------------------
def append_history(self, entry: str, *, max_chars: int | None = None) -> int:
def append_history(
self,
entry: str,
*,
max_chars: int | None = None,
session_key: str | None = None,
) -> int:
"""Append *entry* to history.jsonl and return its auto-incrementing cursor.
Entries are passed through `strip_think` to drop template-level leaks
@@ -272,6 +280,8 @@ class MemoryStore:
cursor,
)
record = {"cursor": cursor, "timestamp": ts, "content": content}
if session_key:
record["session_key"] = session_key
with open(self.history_file, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
self._cursor_file.write_text(str(cursor), encoding="utf-8")
@@ -322,6 +332,36 @@ class MemoryStore:
"""Return history entries with a valid cursor > *since_cursor*."""
return [e for e, c in self._iter_valid_entries() if c > since_cursor]
@classmethod
def _is_internal_history_session(cls, session_key: str | None) -> bool:
if not session_key:
return False
return (
session_key in cls._INTERNAL_HISTORY_SESSION_KEYS
or session_key.startswith(cls._INTERNAL_HISTORY_SESSION_PREFIXES)
)
def read_recent_history_for_prompt(
self,
since_cursor: int,
*,
session_key: str | None,
unified_session: bool = False,
) -> list[dict[str, Any]]:
"""Return unprocessed history entries safe to inject into a turn prompt."""
entries = self.read_unprocessed_history(since_cursor=since_cursor)
if session_key is None:
return entries
if not unified_session:
return [e for e in entries if e.get("session_key") == session_key]
return [
entry
for entry in entries
if (entry_session := entry.get("session_key")) == session_key
or not self._is_internal_history_session(entry_session)
]
def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*."""
if self.max_history_entries <= 0:
@@ -489,13 +529,20 @@ class MemoryStore:
)
return "\n".join(lines)
def raw_archive(self, messages: list[dict], *, max_chars: int | None = None) -> None:
def raw_archive(
self,
messages: list[dict],
*,
max_chars: int | None = None,
session_key: str | None = None,
) -> None:
"""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(
f"[RAW] {len(messages)} messages\n"
f"{formatted}"
f"{formatted}",
session_key=session_key,
)
logger.warning(
"Memory consolidation degraded: raw-archived {} messages", len(messages)
@@ -570,6 +617,7 @@ class Consolidator:
get_tool_definitions: Callable[[], list[dict[str, Any]]],
max_completion_tokens: int = 4096,
consolidation_ratio: float = 0.5,
unified_session: bool = False,
):
self.store = store
self.provider = provider
@@ -578,6 +626,7 @@ class Consolidator:
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = max_completion_tokens
self.consolidation_ratio = consolidation_ratio
self.unified_session = unified_session
self._build_messages = build_messages
self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
@@ -685,7 +734,7 @@ class Consolidator:
len(chunk),
replay_max_messages,
)
summary = await self.archive(chunk)
summary = await self.archive(chunk, session_key=session.key)
session.last_consolidated = end_idx
self.sessions.save(session)
return summary
@@ -716,6 +765,8 @@ class Consolidator:
sender_id=None,
session_summary=summary,
session_metadata=session.metadata,
session_key=session.key,
unified_session=self.unified_session,
)
return estimate_prompt_tokens_chain(
self.provider,
@@ -743,7 +794,12 @@ class Consolidator:
except Exception:
return truncate_text(text, budget * 4)
async def archive(self, messages: list[dict]) -> str | None:
async def archive(
self,
messages: list[dict],
*,
session_key: str | None = None,
) -> str | None:
"""Summarize messages via LLM and append to history.jsonl.
Returns the summary text on success, None if nothing to archive.
@@ -771,11 +827,15 @@ class Consolidator:
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
summary = response.content or "[no summary]"
self.store.append_history(summary, max_chars=_ARCHIVE_SUMMARY_MAX_CHARS)
self.store.append_history(
summary,
max_chars=_ARCHIVE_SUMMARY_MAX_CHARS,
session_key=session_key,
)
return summary
except Exception:
logger.warning("Consolidation LLM call failed, raw-dumping to history")
self.store.raw_archive(messages)
self.store.raw_archive(messages, session_key=session_key)
return None
async def maybe_consolidate_by_tokens(
@@ -858,7 +918,7 @@ class Consolidator:
source,
len(chunk),
)
summary = await self.archive(chunk)
summary = await self.archive(chunk, session_key=session.key)
# Advance the cursor either way: on success the chunk was
# summarized; on failure archive() already raw-archived it as
# a breadcrumb. Re-archiving the same chunk on the next call
@@ -930,7 +990,7 @@ class Consolidator:
last_active = session.updated_at
summary: str | None = ""
if archive_msgs:
summary = await self.archive(archive_msgs)
summary = await self.archive(archive_msgs, session_key=session_key)
if summary and summary != "(nothing)":
session.metadata["_last_summary"] = {
+4
View File
@@ -754,11 +754,15 @@ class AgentRunner:
context.streamed_reasoning = True
await hook.emit_reasoning(delta)
async def _stream_recover() -> None:
await hook.on_stream_end(context, resuming=True)
coro = self.provider.chat_stream_with_retry(
**kwargs,
on_content_delta=_stream,
on_thinking_delta=_thinking,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
on_stream_recover=_stream_recover,
)
elif wants_progress_streaming:
stream_buf = ""
+29 -4
View File
@@ -55,6 +55,7 @@ class ExecToolConfig(Base):
"""Shell exec tool configuration."""
enable: bool = True
timeout: int = Field(default=60, ge=0) # Hard timeout (s); 0 = no limit. Not capped by the per-call max.
path_prepend: str = ""
path_append: str = ""
sandbox: str = ""
allowed_env_keys: list[str] = Field(default_factory=list)
@@ -150,6 +151,7 @@ class ExecTool(Tool):
restrict_to_workspace=ctx.config.restrict_to_workspace,
webui_allow_local_service_access=ctx.config.webui_allow_local_service_access,
sandbox=cfg.sandbox,
path_prepend=cfg.path_prepend,
path_append=cfg.path_append,
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
@@ -166,6 +168,7 @@ class ExecTool(Tool):
webui_allow_local_service_access: bool = True,
allow_local_preview_access: bool | None = None,
sandbox: str = "",
path_prepend: str = "",
path_append: str = "",
allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
@@ -197,6 +200,7 @@ class ExecTool(Tool):
if allow_local_preview_access is not None:
webui_allow_local_service_access = allow_local_preview_access
self.webui_allow_local_service_access = webui_allow_local_service_access
self.path_prepend = path_prepend
self.path_append = path_append
self.allowed_env_keys = allowed_env_keys or []
self._session_manager = session_manager or DEFAULT_EXEC_SESSION_MANAGER
@@ -411,12 +415,11 @@ class ExecTool(Tool):
effective_timeout = self._resolve_timeout(timeout)
env = self._build_env()
if self.path_append:
if self.path_prepend or self.path_append:
if _IS_WINDOWS:
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
env["PATH"] = self._compose_path(env.get("PATH", ""))
else:
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
command = self._wrap_path_export(command, env)
shell_program, shell_error = self._resolve_shell(shell)
if shell_error:
@@ -431,6 +434,28 @@ class ExecTool(Tool):
login=True if login is None else login,
)
def _compose_path(self, current_path: str) -> str:
parts = []
if self.path_prepend:
parts.append(self.path_prepend)
if current_path:
parts.append(current_path)
if self.path_append:
parts.append(self.path_append)
return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments = []
if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND")
segments.append("$PATH")
if self.path_append:
env["NANOBOT_PATH_APPEND"] = self.path_append
segments.append("$NANOBOT_PATH_APPEND")
path_expr = os.pathsep.join(segments)
return f'export PATH="{path_expr}"; {command}'
@staticmethod
async def _spawn(
command: str, cwd: str, env: dict[str, str],
+45 -8
View File
@@ -1,5 +1,7 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
from __future__ import annotations
import asyncio
import importlib.util
import json
@@ -11,10 +13,8 @@ import uuid
from collections import OrderedDict
from contextlib import suppress
from dataclasses import dataclass
from typing import Any, Literal
from typing import TYPE_CHECKING, Any, Literal
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from pydantic import Field
from nanobot.bus.events import OutboundMessage
@@ -25,8 +25,42 @@ from nanobot.config.schema import Base
from nanobot.utils.helpers import safe_filename
from nanobot.utils.logging_bridge import redirect_lib_logging
if TYPE_CHECKING:
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
def _load_lark_runtime() -> tuple[Any, str, str]:
"""Import the heavy Feishu SDK lazily.
lark_oapi imports a large generated API surface at module import time, so
keep it out of channel discovery and constructor paths.
"""
import sys
ws_client_already_imported = "lark_oapi.ws.client" in sys.modules
import lark_oapi as lark
import lark_oapi.ws.client as lark_ws_client
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
if (
not ws_client_already_imported
and threading.current_thread() is not threading.main_thread()
):
import_loop = getattr(lark_ws_client, "loop", None)
if (
import_loop is not None
and not import_loop.is_running()
and not import_loop.is_closed()
):
import_loop.close()
lark_ws_client.loop = None
with suppress(Exception):
asyncio.set_event_loop(None)
return lark, FEISHU_DOMAIN, LARK_DOMAIN
# Message type display mapping
MSG_TYPE_MAP = {
"image": "[image]",
@@ -297,13 +331,11 @@ class FeishuChannel(BaseChannel):
return FeishuConfig().model_dump(by_alias=True)
def __init__(self, config: Any, bus: MessageBus):
import lark_oapi as lark
if isinstance(config, dict):
config = FeishuConfig.model_validate(config)
super().__init__(config, bus)
self.config: FeishuConfig = config
self._client: lark.Client = None
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
@@ -329,7 +361,7 @@ class FeishuChannel(BaseChannel):
self.logger.error("app_id and app_secret not configured")
return
import lark_oapi as lark
lark, feishu_domain, lark_domain = await asyncio.to_thread(_load_lark_runtime)
redirect_lib_logging("Lark")
@@ -337,7 +369,7 @@ class FeishuChannel(BaseChannel):
self._loop = asyncio.get_running_loop()
# Create Lark client for sending messages
domain = LARK_DOMAIN if self.config.domain == "lark" else FEISHU_DOMAIN
domain = lark_domain if self.config.domain == "lark" else feishu_domain
self._client = (
lark.Client.builder()
.app_id(self.config.app_id)
@@ -397,6 +429,7 @@ class FeishuChannel(BaseChannel):
import lark_oapi.ws.client as _lark_ws_client
previous_loop = getattr(_lark_ws_client, "loop", None)
ws_loop = asyncio.new_event_loop()
asyncio.set_event_loop(ws_loop)
# Patch the module-level loop used by lark's ws Client.start()
@@ -410,6 +443,10 @@ class FeishuChannel(BaseChannel):
if self._running:
time.sleep(5)
finally:
if getattr(_lark_ws_client, "loop", None) is ws_loop:
_lark_ws_client.loop = previous_loop
with suppress(Exception):
asyncio.set_event_loop(None)
ws_loop.close()
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
+1 -1
View File
@@ -212,7 +212,7 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
loop.sessions.save(session)
loop.sessions.invalidate(session.key)
if snapshot:
loop._schedule_background(loop.consolidator.archive(snapshot))
loop._schedule_background(loop.consolidator.archive(snapshot, session_key=ctx.key))
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="New session started.",
+1 -3
View File
@@ -7,7 +7,6 @@ from pathlib import Path
from typing import Any
import pydantic
from loguru import logger
from pydantic import BaseModel
from nanobot.config.schema import Config, _resolve_tool_config_refs
@@ -55,8 +54,7 @@ def load_config(config_path: Path | None = None) -> Config:
data = _migrate_config(data)
config = Config.model_validate(data)
except (json.JSONDecodeError, ValueError, pydantic.ValidationError) as e:
logger.warning("Failed to load config from {}: {}", path, e)
logger.warning("Using default configuration.")
raise ValueError(f"Failed to load config from {path}: {e}") from e
_apply_ssrf_whitelist(config)
return config
+34 -4
View File
@@ -631,6 +631,7 @@ class LLMProvider(ABC):
on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_thinking_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
retry_mode: str = "standard",
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
) -> LLMResponse:
@@ -651,6 +652,12 @@ class LLMProvider(ABC):
if on_content_delta:
await on_content_delta(text)
async def _recover_stream() -> None:
nonlocal has_streamed_content
if on_stream_recover:
await on_stream_recover()
has_streamed_content = False
kw: dict[str, Any] = dict(
messages=messages, tools=tools, model=model,
max_tokens=max_tokens, temperature=temperature,
@@ -659,6 +666,8 @@ class LLMProvider(ABC):
on_thinking_delta=on_thinking_delta,
on_tool_call_delta=on_tool_call_delta,
)
if on_stream_recover and getattr(self, "supports_stream_recover_callback", False):
kw["on_stream_recover"] = _recover_stream
return await self._run_with_retry(
self._safe_chat_stream,
kw,
@@ -666,6 +675,7 @@ class LLMProvider(ABC):
retry_mode=retry_mode,
on_retry_wait=on_retry_wait,
should_retry_guard=lambda: not has_streamed_content,
on_stream_recover=_recover_stream if on_stream_recover else None,
)
async def chat_with_retry(
@@ -813,6 +823,7 @@ class LLMProvider(ABC):
retry_mode: str,
on_retry_wait: Callable[[str], Awaitable[None]] | None,
should_retry_guard: Callable[[], bool] | None = None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse:
attempt = 0
delays = list(self._CHAT_RETRY_DELAYS)
@@ -827,10 +838,29 @@ class LLMProvider(ABC):
return response
last_response = response
if should_retry_guard is not None and not should_retry_guard():
logger.warning(
"LLM stream failed after content was emitted; skipping retry"
)
return response
is_timeout = (response.error_kind or "").lower() == "timeout"
if is_timeout:
if on_stream_recover:
logger.warning(
"LLM stream stalled after content was emitted; "
"starting a new stream segment and retrying"
)
await on_stream_recover()
else:
logger.warning(
"LLM stream stalled after content was emitted; "
"suppressing delta callbacks and retrying"
)
kw.setdefault("on_content_delta", None)
kw["on_content_delta"] = None
kw["on_thinking_delta"] = None
kw["on_tool_call_delta"] = None
should_retry_guard = None
else:
logger.warning(
"LLM stream failed after content was emitted; skipping retry"
)
return response
error_key = ((response.content or "").strip().lower() or None)
if error_key and error_key == last_error_key:
identical_error_count += 1
+47 -11
View File
@@ -58,19 +58,24 @@ _FALLBACK_ERROR_TOKENS = (
class FallbackProvider(LLMProvider):
"""Wrap a primary provider and transparently failover to fallback models.
When the primary model returns an error and no content has been streamed yet,
the wrapper tries each fallback model in order. Each fallback model may
reside on a different provider a factory callable creates the underlying
provider on-the-fly.
When the primary model returns a fallbackable error before content has been
streamed, the wrapper tries each fallback model in order. Streamed timeout
errors are the recovery exception: the caller may close the current stream
segment, then the wrapper continues failover with later deltas in a new
segment. Each fallback model may reside on a different provider a factory
callable creates the underlying provider on-the-fly.
Key design:
- Failover is request-scoped (the wrapper itself is stateless between turns).
- Skipped when content was already streamed to avoid duplicate output.
- Skipped when content was already streamed to avoid duplicate output,
except timeout recovery can resume in a new stream segment.
- Recursive failover is prevented by the factory returning plain providers.
- Primary provider is circuit-broken after repeated failures to avoid
wasting requests on a known-bad endpoint.
"""
supports_stream_recover_callback = True
def __init__(
self,
primary: LLMProvider,
@@ -116,6 +121,7 @@ class FallbackProvider(LLMProvider):
)
async def chat_stream(self, **kwargs: Any) -> LLMResponse:
on_stream_recover = kwargs.pop("on_stream_recover", None)
if not self._has_fallbacks:
return await self._primary.chat_stream(**kwargs)
@@ -130,7 +136,10 @@ class FallbackProvider(LLMProvider):
kwargs["on_content_delta"] = _tracking_delta
return await self._try_with_fallback(
lambda p, kw: p.chat_stream(**kw), kwargs, has_streamed=has_streamed
lambda p, kw: p.chat_stream(**kw),
kwargs,
has_streamed=has_streamed,
on_stream_recover=on_stream_recover,
)
async def _try_with_fallback(
@@ -138,6 +147,7 @@ class FallbackProvider(LLMProvider):
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
kwargs: dict[str, Any],
has_streamed: list[bool] | None,
on_stream_recover: Callable[[], Awaitable[None]] | None = None,
) -> LLMResponse:
primary_model = kwargs.get("model") or self._primary.get_default_model()
@@ -149,10 +159,23 @@ class FallbackProvider(LLMProvider):
return response
if has_streamed is not None and has_streamed[0]:
logger.warning(
"Primary model error but content already streamed; skipping failover"
)
return response
is_timeout = (response.error_kind or "").lower() == "timeout"
if is_timeout:
logger.warning(
"Primary model '{}' stream stalled after content was emitted; "
"attempting failover anyway",
primary_model,
)
has_streamed[0] = False
if on_stream_recover:
await on_stream_recover()
else:
kwargs["on_content_delta"] = None
else:
logger.warning(
"Primary model error but content already streamed; skipping failover"
)
return response
if not self._should_fallback(response):
logger.warning(
@@ -177,7 +200,20 @@ class FallbackProvider(LLMProvider):
for idx, fallback in enumerate(self._fallback_presets):
fallback_model = fallback.model
if has_streamed is not None and has_streamed[0]:
break
is_timeout = (
last_response is not None
and (last_response.error_kind or "").lower() == "timeout"
)
if is_timeout and on_stream_recover:
logger.warning(
"Fallback model '{}' stream stalled after content was emitted; "
"starting a new stream segment and trying next fallback",
self._fallback_presets[idx - 1].model if idx > 0 else primary_model,
)
has_streamed[0] = False
await on_stream_recover()
else:
break
if idx == 0 and primary_skipped:
logger.info(
"Primary model '{}' circuit open, trying fallback '{}'",
+10
View File
@@ -15,6 +15,7 @@ from zoneinfo import ZoneInfo
import httpx
from nanobot import __version__
from nanobot.audio.transcription import resolve_transcription_config
from nanobot.audio.transcription_registry import (
resolve_transcription_provider,
@@ -37,6 +38,13 @@ from nanobot.webui.workspaces import (
QueryParams = dict[str, list[str]]
RuntimeSurface = Literal["browser", "native"]
def _version_payload() -> dict[str, Any]:
"""Return version info for the settings payload."""
return {
"current": __version__,
}
_RUNTIME_CAPABILITIES = {
"can_restart_engine": False,
"can_pick_folder": False,
@@ -801,9 +809,11 @@ def settings_payload(
"mcp_server_count": len(config.tools.mcp_servers),
"exec_enabled": exec_config.enable,
"exec_sandbox": exec_config.sandbox or None,
"exec_path_prepend_set": bool(exec_config.path_prepend),
"exec_path_append_set": bool(exec_config.path_append),
},
"requires_restart": requires_restart,
"version": _version_payload(),
}
return decorate_settings_payload(
payload,
+15
View File
@@ -36,6 +36,7 @@ from nanobot.webui.settings_api import (
update_transcription_settings,
update_web_search_settings,
)
from nanobot.webui.version_check import check_for_update
QueryParams = dict[str, list[str]]
@@ -117,6 +118,8 @@ class WebUISettingsRouter:
return await self._handle_settings_cli_apps_action(request, "test")
if path == "/api/settings/mcp-presets":
return await self._handle_settings_mcp_presets(request)
if path == "/api/settings/version-check":
return await self._handle_settings_version_check(request)
mcp_action = _MCP_PRESET_ACTIONS_BY_PATH.get(path)
if mcp_action is not None:
return await self._handle_settings_mcp_presets(request, mcp_action)
@@ -347,3 +350,15 @@ class WebUISettingsRouter:
if action is None:
return self._json_response(payload)
return self._json_response(self._with_restart_state(payload, section="runtime"))
async def _handle_settings_version_check(self, request: WsRequest) -> Response:
if not self._authorized(request):
return self._unauthorized()
try:
update_info = await asyncio.to_thread(check_for_update)
except Exception:
self.logger.exception("version check failed")
return self._error_response(500, "version check failed")
return self._json_response({
"updateAvailable": update_info,
})
+51
View File
@@ -0,0 +1,51 @@
"""On-demand version checker for nanobot-ai releases.
Checks PyPI for newer versions when explicitly requested (no background polling).
"""
from __future__ import annotations
import logging
import time
from typing import Any
import httpx
from nanobot import __version__
logger = logging.getLogger(__name__)
_PYPI_URL = "https://pypi.org/pypi/nanobot-ai/json"
_CACHE_TTL_S = 300 # 5 minutes cache to avoid hammering PyPI
_cache: tuple[float, str | None] = (0.0, None)
def check_for_update() -> dict[str, Any] | None:
"""Check PyPI for a newer version. Returns update info dict or None if up-to-date.
Uses a short cache to avoid repeated requests within the TTL window.
This is a blocking call invoke from a thread or background task.
"""
global _cache
now = time.monotonic()
cached_at, cached_val = _cache
if now - cached_at < _CACHE_TTL_S and cached_val is not None:
latest = cached_val
else:
try:
resp = httpx.get(_PYPI_URL, timeout=5.0, follow_redirects=True)
resp.raise_for_status()
latest = resp.json().get("info", {}).get("version")
except Exception:
logger.debug("PyPI version check failed", exc_info=True)
return None
_cache = (now, latest)
if not latest or latest == __version__:
return None
return {
"currentVersion": __version__,
"latestVersion": latest,
"pypiUrl": "https://pypi.org/project/nanobot-ai/",
}