Merge origin/main into webui-settings

Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-26 13:05:32 +00:00
24 changed files with 1428 additions and 91 deletions
+2
View File
@@ -191,6 +191,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,
@@ -274,6 +275,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,
+3 -1
View File
@@ -435,6 +435,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
@@ -442,6 +443,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] = (
@@ -568,7 +570,7 @@ class Consolidator:
lock = self.get_lock(session.key)
async with lock:
budget = self._input_token_budget
target = budget // 2
target = int(budget * self.consolidation_ratio)
try:
estimated, source = self.estimate_session_prompt_tokens(
session,
+19 -3
View File
@@ -42,6 +42,10 @@ class MessageTool(Tool):
default=default_message_id,
)
self._sent_in_turn_var: ContextVar[bool] = ContextVar("message_sent_in_turn", default=False)
self._record_channel_delivery_var: ContextVar[bool] = ContextVar(
"message_record_channel_delivery",
default=False,
)
def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
"""Set the current message context."""
@@ -57,6 +61,14 @@ class MessageTool(Tool):
"""Reset per-turn send tracking."""
self._sent_in_turn = False
def set_record_channel_delivery(self, active: bool):
"""Mark tool-sent messages as proactive channel deliveries."""
return self._record_channel_delivery_var.set(active)
def reset_record_channel_delivery(self, token) -> None:
"""Restore previous proactive delivery recording state."""
self._record_channel_delivery_var.reset(token)
@property
def _sent_in_turn(self) -> bool:
return self._sent_in_turn_var.get()
@@ -117,15 +129,19 @@ class MessageTool(Tool):
if not self._send_callback:
return "Error: Message sending not configured"
metadata = {
"message_id": message_id,
} if message_id else {}
if self._record_channel_delivery_var.get():
metadata["_record_channel_delivery"] = True
msg = OutboundMessage(
channel=channel,
chat_id=chat_id,
content=content,
media=media or [],
buttons=buttons or [],
metadata={
"message_id": message_id,
} if message_id else {},
metadata=metadata,
)
try:
+5 -4
View File
@@ -136,9 +136,10 @@ class ExecTool(Tool):
if self.path_append:
if _IS_WINDOWS:
env["PATH"] = env.get("PATH", "") + ";" + self.path_append
env["PATH"] = env.get("PATH", "") + os.pathsep + self.path_append
else:
command = f'export PATH="$PATH:{self.path_append}"; {command}'
env["NANOBOT_PATH_APPEND"] = self.path_append
command = f'export PATH="$PATH{os.pathsep}$NANOBOT_PATH_APPEND"; {command}'
try:
process = await self._spawn(command, cwd, env)
@@ -298,8 +299,8 @@ class ExecTool(Tool):
continue
media_path = get_media_dir().resolve()
if (p.is_absolute()
and cwd_path not in p.parents
if (p.is_absolute()
and cwd_path not in p.parents
and p != cwd_path
and media_path not in p.parents
and p != media_path
+159 -41
View File
@@ -13,6 +13,7 @@ from dataclasses import dataclass
from typing import Any, Literal
from lark_oapi.api.im.v1.model import MentionEvent, P2ImMessageReceiveV1
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
from loguru import logger
from pydantic import Field
@@ -22,8 +23,6 @@ from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import Base
from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
# Message type display mapping
@@ -308,6 +307,8 @@ class FeishuChannel(BaseChannel):
self._loop: asyncio.AbstractEventLoop | None = None
self._stream_bufs: dict[str, _FeishuStreamBuf] = {}
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
def _register_optional_event(builder: Any, method_name: str, handler: Any) -> Any:
@@ -549,8 +550,11 @@ class FeishuChannel(BaseChannel):
return None
async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> str | None:
"""
Add a reaction emoji to a message (non-blocking).
"""Add a reaction emoji to a message.
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
"""
@@ -594,6 +598,36 @@ class FeishuChannel(BaseChannel):
loop = asyncio.get_running_loop()
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)))
@staticmethod
def _stream_key(chat_id: str, metadata: dict[str, Any] | None = None) -> str:
"""Scope streaming buffers to the inbound message when available."""
meta = metadata or {}
return meta.get("message_id") or chat_id
# Regex to match markdown tables (header + separator + data rows)
_TABLE_RE = re.compile(
r"((?:^[ \t]*\|.+\|[ \t]*\n)(?:^[ \t]*\|[-:\s|]+\|[ \t]*\n)(?:^[ \t]*\|.+\|[ \t]*\n?)+)",
@@ -1101,17 +1135,23 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: error fetching parent message {}: {}", message_id, e)
return None
def _reply_message_sync(self, parent_message_id: str, msg_type: str, content: str) -> bool:
"""Reply to an existing Feishu message using the Reply API (synchronous)."""
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).
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
try:
body_builder = ReplyMessageRequestBody.builder().msg_type(msg_type).content(content)
if reply_in_thread:
body_builder = body_builder.reply_in_thread(True)
request = (
ReplyMessageRequest.builder()
.message_id(parent_message_id)
.request_body(
ReplyMessageRequestBody.builder().msg_type(msg_type).content(content).build()
)
.request_body(body_builder.build())
.build()
)
response = self._client.im.v1.message.reply(request)
@@ -1166,8 +1206,19 @@ class FeishuChannel(BaseChannel):
logger.error("Error sending Feishu {} message: {}", msg_type, e)
return None
def _create_streaming_card_sync(self, receive_id_type: str, chat_id: str) -> str | None:
"""Create a CardKit streaming card, send it to chat, return card_id."""
def _create_streaming_card_sync(
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
card_json = {
@@ -1196,13 +1247,19 @@ class FeishuChannel(BaseChannel):
return None
card_id = getattr(response.data, "card_id", None)
if card_id:
message_id = self._send_message_sync(
receive_id_type,
chat_id,
"interactive",
json.dumps({"type": "card", "data": {"card_id": card_id}}),
card_content = json.dumps(
{"type": "card", "data": {"card_id": card_id}}, ensure_ascii=False
)
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
logger.warning(
"Created streaming card {} but failed to send it to {}", card_id, chat_id
@@ -1292,23 +1349,27 @@ class FeishuChannel(BaseChannel):
_stream_end: Finalize the streaming card.
_tool_hint: Delta is a formatted tool hint (for display only).
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:
return
meta = metadata or {}
stream_key = self._stream_key(chat_id, meta)
loop = asyncio.get_running_loop()
rid_type = "chat_id" if chat_id.startswith("oc_") else "open_id"
# --- stream end: final update or fallback ---
if meta.get("_stream_end"):
if (message_id := meta.get("message_id")) and (reaction_id := meta.get("reaction_id")):
await self._remove_reaction(message_id, reaction_id)
message_id = meta.get("message_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
if self.config.done_emoji and message_id:
if 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(stream_key, None)
if not buf or not buf.text:
return
# Try to finalize via streaming card; if that fails (e.g.
@@ -1343,24 +1404,45 @@ class FeishuChannel(BaseChannel):
{"config": {"wide_screen_mode": True}, "elements": chunk},
ensure_ascii=False,
)
await loop.run_in_executor(
None, self._send_message_sync, rid_type, chat_id, "interactive", card
)
# Fallback: reply via the Reply API for group chats.
# 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
# --- accumulate delta ---
buf = self._stream_bufs.get(chat_id)
buf = self._stream_bufs.get(stream_key)
if buf is None:
buf = _FeishuStreamBuf()
self._stream_bufs[chat_id] = buf
self._stream_bufs[stream_key] = buf
buf.text += delta
if not buf.text.strip():
return
now = time.monotonic()
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(
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:
buf.card_id = card_id
@@ -1393,7 +1475,7 @@ class FeishuChannel(BaseChannel):
hint = (msg.content or "").strip()
if not hint:
return
buf = self._stream_bufs.get(msg.chat_id)
buf = self._stream_bufs.get(self._stream_key(msg.chat_id, msg.metadata))
if buf and buf.card_id:
# Delegate to send_delta so tool hints get the same
# throttling (and card creation) as regular text deltas.
@@ -1404,37 +1486,59 @@ class FeishuChannel(BaseChannel):
return
# No active streaming card — send as a regular
# interactive card with the same 🔧 prefix style.
# Use reply API for group chats so the hint stays in topic.
card = json.dumps(
{"config": {"wide_screen_mode": True}, "elements": [
{"tag": "markdown", "content": self._format_tool_hint_delta(hint)},
]},
ensure_ascii=False,
)
await loop.run_in_executor(
None, self._send_message_sync, receive_id_type, msg.chat_id, "interactive", card
)
_th_msg_id = msg.metadata.get("message_id")
_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
# 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
# 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
_msg_id = msg.metadata.get("message_id")
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
elif msg.metadata.get("thread_id"):
reply_message_id = (
msg.metadata.get("root_id") or msg.metadata.get("message_id") or None
)
reply_message_id = _msg_id
first_send = True # tracks whether the reply has already been used
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
if reply_message_id and first_send:
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:
return
# Fall back to regular send if reply fails
@@ -1543,8 +1647,13 @@ class FeishuChannel(BaseChannel):
logger.debug("Feishu: skipping group message (not mentioned)")
return
# Add reaction
reaction_id = await self._add_reaction(message_id, self.config.react_emoji)
# Add reaction (non-blocking — tracked background task)
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
content_parts = []
@@ -1624,6 +1733,15 @@ class FeishuChannel(BaseChannel):
if not content and not media_paths:
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
reply_to = chat_id if chat_type == "group" else sender_id
await self._handle_message(
@@ -1633,13 +1751,13 @@ class FeishuChannel(BaseChannel):
media=media_paths,
metadata={
"message_id": message_id,
"reaction_id": reaction_id,
"chat_type": chat_type,
"msg_type": msg_type,
"parent_id": parent_id,
"root_id": root_id,
"thread_id": thread_id,
},
session_key=session_key,
)
except Exception as e:
+37 -3
View File
@@ -70,6 +70,7 @@ class ConversationRef:
activity_id: str | None = None
conversation_type: str | None = None
tenant_id: str | None = None
updated_at: float | None = None
class MSTeamsChannel(BaseChannel):
@@ -220,7 +221,6 @@ class MSTeamsChannel(BaseChannel):
token = await self._get_access_token()
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)
url = f"{base_url}/{ref.activity_id}" if use_thread_reply else base_url
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
@@ -233,7 +233,7 @@ class MSTeamsChannel(BaseChannel):
payload["replyToId"] = ref.activity_id
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()
logger.info("MSTeams message sent to {}", ref.conversation_id)
except Exception as e:
@@ -289,7 +289,9 @@ class MSTeamsChannel(BaseChannel):
activity_id=activity_id or None,
conversation_type=conversation_type or None,
tenant_id=str((channel_data.get("tenant") or {}).get("id") or "") or None,
updated_at=time.time(),
)
self._save_refs()
await self._handle_message(
@@ -310,10 +312,12 @@ class MSTeamsChannel(BaseChannel):
"""Extract the user-authored text from a Teams activity."""
text = str(activity.get("text") or "")
text = self._strip_possible_bot_mention(text)
text = self._normalize_html_whitespace(text)
channel_data = activity.get("channelData") or {}
reply_to_id = str(activity.get("replyToId") or "").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")
preview_lines = [line.strip() for line in normalized_preview.split("\n")]
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)
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:
"""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:
return ""
@@ -494,6 +504,14 @@ class MSTeamsChannel(BaseChannel):
def _save_refs(self) -> None:
"""Persist conversation references."""
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 = {
key: {
"service_url": ref.service_url,
@@ -502,6 +520,7 @@ class MSTeamsChannel(BaseChannel):
"activity_id": ref.activity_id,
"conversation_type": ref.conversation_type,
"tenant_id": ref.tenant_id,
"updated_at": ref.updated_at,
}
for key, ref in self._conversation_refs.items()
}
@@ -509,6 +528,21 @@ class MSTeamsChannel(BaseChannel):
except Exception as 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:
"""Fetch an access token for Bot Framework / Azure Bot auth."""
+72 -12
View File
@@ -537,6 +537,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,
)
@@ -597,6 +598,8 @@ def _run_gateway(
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.loop import AgentLoop
from nanobot.agent.runtime import build_agent_runtime, load_agent_runtime
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool
from nanobot.bus.queue import MessageBus
from nanobot.channels.manager import ChannelManager
from nanobot.cron.service import CronService
@@ -647,11 +650,52 @@ def _run_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,
runtime_loader=load_agent_runtime,
runtime_signature=runtime.signature,
)
from nanobot.agent.loop import UNIFIED_SESSION_KEY
from nanobot.bus.events import OutboundMessage
def _channel_session_key(channel: str, chat_id: str) -> str:
return (
UNIFIED_SESSION_KEY
if config.agents.defaults.unified_session
else f"{channel}:{chat_id}"
)
async def _deliver_to_channel(msg: OutboundMessage, *, record: bool = False) -> None:
"""Publish a user-visible message and mirror it into that channel's session."""
metadata = dict(msg.metadata or {})
record = record or bool(metadata.pop("_record_channel_delivery", False))
if metadata != (msg.metadata or {}):
msg = OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=msg.content,
reply_to=msg.reply_to,
media=msg.media,
metadata=metadata,
buttons=msg.buttons,
)
if (
record
and msg.channel != "cli"
and msg.content.strip()
and hasattr(session_manager, "get_or_create")
and hasattr(session_manager, "save")
):
session = session_manager.get_or_create(_channel_session_key(msg.channel, msg.chat_id))
session.add_message("assistant", msg.content, _channel_delivery=True)
session_manager.save(session)
await bus.publish_outbound(msg)
message_tool = getattr(agent, "tools", {}).get("message")
if isinstance(message_tool, MessageTool):
message_tool.set_send_callback(_deliver_to_channel)
# Set cron callback (needs agent)
async def on_cron_job(job: CronJob) -> str | None:
"""Execute a cron job through the agent."""
@@ -664,8 +708,6 @@ def _run_gateway(
logger.exception("Dream cron job failed")
return None
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.tools.message import MessageTool
from nanobot.utils.evaluator import evaluate_response
reminder_note = (
@@ -682,6 +724,10 @@ def _run_gateway(
async def _silent(*_args, **_kwargs):
pass
message_record_token = None
if isinstance(message_tool, MessageTool):
message_record_token = message_tool.set_record_channel_delivery(True)
try:
resp = await agent.process_direct(
reminder_note,
@@ -693,10 +739,11 @@ def _run_gateway(
finally:
if isinstance(cron_tool, CronTool) and cron_token is not None:
cron_tool.reset_cron_context(cron_token)
if isinstance(message_tool, MessageTool) and message_record_token is not None:
message_tool.reset_record_channel_delivery(message_record_token)
response = resp.content if resp else ""
message_tool = agent.tools.get("message")
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
return response
@@ -705,12 +752,14 @@ def _run_gateway(
response, reminder_note, provider, agent.model,
)
if should_notify:
from nanobot.bus.events import OutboundMessage
await bus.publish_outbound(OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response,
))
await _deliver_to_channel(
OutboundMessage(
channel=job.payload.channel or "cli",
chat_id=job.payload.to,
content=response,
),
record=True,
)
return response
cron.on_job = on_cron_job
@@ -760,12 +809,22 @@ def _run_gateway(
return resp.content if resp else ""
async def on_heartbeat_notify(response: str) -> None:
"""Deliver a heartbeat response to the user's channel."""
from nanobot.bus.events import OutboundMessage
"""Deliver a heartbeat response to the user's channel.
In addition to publishing the outbound message, this injects the
delivered text as an assistant turn into the *target channel's*
session. Without this, a user reply on the channel (e.g. "Sure")
lands in a session that has no context about the heartbeat message
and the agent cannot follow through.
"""
channel, chat_id = _pick_heartbeat_target()
if channel == "cli":
return # No external channel available to deliver to
await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response))
await _deliver_to_channel(
OutboundMessage(channel=channel, chat_id=chat_id, content=response),
record=True,
)
hb_cfg = config.gateway.heartbeat
heartbeat = HeartbeatService(
@@ -968,6 +1027,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
@@ -90,6 +90,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)
+99 -4
View File
@@ -3,17 +3,20 @@
from __future__ import annotations
import asyncio
import json
import hashlib
import importlib.util
import json
import os
import secrets
import string
import time
import uuid
from collections.abc import Awaitable, Callable
from ipaddress import ip_address
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
import httpx
import json_repair
from loguru import logger
@@ -159,6 +162,37 @@ _RESPONSES_FAILURE_THRESHOLD = 3
_RESPONSES_PROBE_INTERVAL_S = 300 # 5 minutes
def _is_local_endpoint(
spec: "ProviderSpec | None",
api_base: str | None,
) -> bool:
"""Return True when the endpoint is a local or LAN model server.
Matches either the provider spec's ``is_local`` flag or common private-
network patterns in the base URL (localhost, 127.x, 192.168.x, 10.x,
172.16-31.x, Docker ``host.docker.internal``).
"""
if spec and spec.is_local:
return True
if not api_base:
return False
raw = api_base.strip().lower()
parsed = urlparse(raw if "://" in raw else f"//{raw}")
try:
host = parsed.hostname
except ValueError:
return False
if host in {"localhost", "host.docker.internal"}:
return True
if not host:
return False
try:
addr = ip_address(host)
except ValueError:
return False
return addr.is_loopback or addr.is_private
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:
@@ -208,11 +242,27 @@ class OpenAICompatProvider(LLMProvider):
if extra_headers:
default_headers.update(extra_headers)
# Local model servers (Ollama, llama.cpp, vLLM) often close idle
# HTTP connections before the client-side keepalive expires. When
# two LLM calls happen seconds apart (e.g. heartbeat _decide then
# process_direct), the second call may grab a now-dead pooled
# connection, causing a transient APIConnectionError on every first
# attempt. Disabling keepalive for local endpoints avoids this by
# opening a fresh connection for each request, which is cheap on a
# LAN. Cloud providers benefit from keepalive, so we leave the
# default pool settings for them.
http_client: httpx.AsyncClient | None = None
if _is_local_endpoint(spec, effective_base):
http_client = httpx.AsyncClient(
limits=httpx.Limits(keepalive_expiry=0),
)
self._client = AsyncOpenAI(
api_key=api_key or "no-key",
base_url=effective_base,
default_headers=default_headers,
max_retries=0,
http_client=http_client,
)
# Responses API circuit breaker: skip after repeated failures,
@@ -334,6 +384,47 @@ class OpenAICompatProvider(LLMProvider):
clean["tool_call_id"] = map_id(clean["tool_call_id"])
return self._enforce_role_alternation(sanitized)
def _drop_deepseek_incomplete_reasoning_history(
self,
messages: list[dict[str, Any]],
reasoning_effort: str | None,
) -> list[dict[str, Any]]:
if (
not self._spec
or self._spec.name != "deepseek"
or not reasoning_effort
or reasoning_effort.lower() == "none"
):
return messages
bad_idx = None
for idx, msg in enumerate(messages):
if (
msg.get("role") == "assistant"
and msg.get("tool_calls")
and not msg.get("reasoning_content")
):
bad_idx = idx
if bad_idx is None:
return messages
keep_from = None
for idx in range(bad_idx + 1, len(messages)):
if messages[idx].get("role") == "user":
keep_from = idx
break
if keep_from is None:
trimmed = messages[:bad_idx]
else:
prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"]
trimmed = prefix + messages[keep_from:]
logger.warning(
"Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content",
len(messages) - len(trimmed),
)
return trimmed
# ------------------------------------------------------------------
# Build kwargs
# ------------------------------------------------------------------
@@ -374,6 +465,10 @@ class OpenAICompatProvider(LLMProvider):
if spec and spec.strip_model_prefix:
model_name = model_name.split("/")[-1]
messages = self._drop_deepseek_incomplete_reasoning_history(
messages,
reasoning_effort,
)
kwargs: dict[str, Any] = {
"model": model_name,
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
@@ -709,8 +804,8 @@ class OpenAICompatProvider(LLMProvider):
finish_reason = str(choice0.get("finish_reason") or "stop")
raw_tool_calls: list[Any] = []
# StepFun Plan: fallback to reasoning field when content is empty
if not content and msg0.get("reasoning"):
# StepFun: fallback to reasoning field when content is empty
if not content and msg0.get("reasoning") and self._spec and self._spec.reasoning_as_content:
content = self._extract_text_content(msg0.get("reasoning"))
reasoning_content = msg0.get("reasoning_content")
if not reasoning_content and msg0.get("reasoning"):
@@ -770,7 +865,7 @@ class OpenAICompatProvider(LLMProvider):
finish_reason = ch.finish_reason
if not content and m.content:
content = m.content
if not content and getattr(m, "reasoning", None):
if not content and getattr(m, "reasoning", None) and self._spec and self._spec.reasoning_as_content:
content = m.reasoning
tool_calls = []
+6
View File
@@ -71,6 +71,11 @@ class ProviderSpec:
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
thinking_style: str = ""
# When True, treat the "reasoning" response field as formal content
# when "content" is empty. Only set this for providers (e.g. StepFun)
# whose API returns the actual answer in "reasoning" instead of "content".
reasoning_as_content: bool = False
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -325,6 +330,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="Step Fun",
backend="openai_compat",
default_api_base="https://api.stepfun.com/v1",
reasoning_as_content=True,
),
# Xiaomi MIMO (小米): OpenAI-compatible API
ProviderSpec(
+6 -2
View File
@@ -46,10 +46,14 @@ class Session:
unconsolidated = self.messages[self.last_consolidated:]
sliced = unconsolidated[-max_messages:]
# Avoid starting mid-turn when possible.
# Avoid starting mid-turn when possible, except for proactive
# assistant deliveries that the user may be replying to.
for i, message in enumerate(sliced):
if message.get("role") == "user":
sliced = sliced[i:]
start = i
if i > 0 and sliced[i - 1].get("_channel_delivery"):
start = i - 1
sliced = sliced[start:]
break
# Drop orphan tool results at the front.