Merge remote-tracking branch 'origin/main' into codex/webui-segmented-transcript-store
This commit is contained in:
@@ -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
@@ -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
@@ -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"] = {
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '{}'",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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/",
|
||||
}
|
||||
Reference in New Issue
Block a user