Merge origin/main into fix/cron-contract-repeat-guard
Made-with: Cursor
This commit is contained in:
+1
-1
@@ -21,7 +21,7 @@ def _resolve_version() -> str:
|
||||
return _pkg_version("nanobot-ai")
|
||||
except PackageNotFoundError:
|
||||
# Source checkouts often import nanobot without installed dist-info.
|
||||
return _read_pyproject_version() or "0.1.5"
|
||||
return _read_pyproject_version() or "0.1.5.post1"
|
||||
|
||||
|
||||
__version__ = _resolve_version()
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
import base64
|
||||
import mimetypes
|
||||
import platform
|
||||
from importlib.resources import files as pkg_files
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.utils.helpers import current_time_str
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
from nanobot.agent.skills import SkillsLoader
|
||||
from nanobot.utils.helpers import build_assistant_message, detect_image_mime
|
||||
from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
@@ -41,7 +40,7 @@ class ContextBuilder:
|
||||
parts.append(bootstrap)
|
||||
|
||||
memory = self.memory.get_memory_context()
|
||||
if memory:
|
||||
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n{memory}")
|
||||
|
||||
always_skills = self.skills.get_always_skills()
|
||||
@@ -50,7 +49,7 @@ class ContextBuilder:
|
||||
if always_content:
|
||||
parts.append(f"# Active Skills\n\n{always_content}")
|
||||
|
||||
skills_summary = self.skills.build_skills_summary()
|
||||
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills))
|
||||
if skills_summary:
|
||||
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
|
||||
|
||||
@@ -116,6 +115,17 @@ class ContextBuilder:
|
||||
|
||||
return "\n\n".join(parts) if parts else ""
|
||||
|
||||
@staticmethod
|
||||
def _is_template_content(content: str, template_path: str) -> bool:
|
||||
"""Check if *content* is identical to the bundled template (user hasn't customized it)."""
|
||||
try:
|
||||
tpl = pkg_files("nanobot") / "templates" / template_path
|
||||
if tpl.is_file():
|
||||
return content.strip() == tpl.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def build_messages(
|
||||
self,
|
||||
history: list[dict[str, Any]],
|
||||
@@ -160,7 +170,6 @@ class ContextBuilder:
|
||||
if not p.is_file():
|
||||
continue
|
||||
raw = p.read_bytes()
|
||||
# Detect real MIME type from magic bytes; fallback to filename guess
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if not mime or not mime.startswith("image/"):
|
||||
continue
|
||||
|
||||
+95
-15
@@ -17,29 +17,32 @@ from nanobot.agent.autocompact import AutoCompact
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
|
||||
from nanobot.agent.memory import Consolidator, Dream
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, ListDirTool, ReadFileTool, WriteFileTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
from nanobot.agent.tools.notebook import NotebookEditTool
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.agent.tools.search import GlobTool, GrepTool
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
from nanobot.agent.tools.self import MyTool
|
||||
from nanobot.agent.tools.spawn import SpawnTool
|
||||
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
|
||||
from nanobot.bus.events import InboundMessage, OutboundMessage
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import image_placeholder_text, truncate_text as truncate_text_fn
|
||||
from nanobot.utils.document import extract_documents
|
||||
from nanobot.utils.helpers import image_placeholder_text
|
||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, WebToolsConfig
|
||||
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
|
||||
@@ -88,6 +91,9 @@ class _LoopHook(AgentHook):
|
||||
await self._on_stream_end(resuming=resuming)
|
||||
self._stream_buf = ""
|
||||
|
||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||
self._loop._current_iteration = context.iteration
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
if self._on_progress:
|
||||
if not self._on_stream:
|
||||
@@ -154,9 +160,11 @@ class AgentLoop:
|
||||
hooks: list[AgentHook] | None = None,
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
tools_config: ToolsConfig | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
||||
from nanobot.config.schema import ExecToolConfig, ToolsConfig, WebToolsConfig
|
||||
|
||||
_tc = tools_config or ToolsConfig()
|
||||
defaults = AgentDefaults()
|
||||
self.bus = bus
|
||||
self.channels_config = channels_config
|
||||
@@ -224,7 +232,7 @@ class AgentLoop:
|
||||
provider=provider,
|
||||
model=self.model,
|
||||
sessions=self.sessions,
|
||||
context_window_tokens=context_window_tokens,
|
||||
context_window_tokens=self.context_window_tokens,
|
||||
build_messages=self.context.build_messages,
|
||||
get_tool_definitions=self.tools.get_definitions,
|
||||
max_completion_tokens=provider.generation.max_tokens,
|
||||
@@ -240,6 +248,10 @@ class AgentLoop:
|
||||
model=self.model,
|
||||
)
|
||||
self._register_default_tools()
|
||||
if _tc.my.enable:
|
||||
self.tools.register(MyTool(loop=self, modify_allowed=_tc.my.allow_set))
|
||||
self._runtime_vars: dict[str, Any] = {}
|
||||
self._current_iteration: int = 0
|
||||
self.commands = CommandRouter()
|
||||
register_builtin_commands(self.commands)
|
||||
|
||||
@@ -306,7 +318,7 @@ class AgentLoop:
|
||||
|
||||
def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None:
|
||||
"""Update context for all tools that need routing info."""
|
||||
for name in ("message", "spawn", "cron"):
|
||||
for name in ("message", "spawn", "cron", "my"):
|
||||
if tool := self.tools.get(name):
|
||||
if hasattr(tool, "set_context"):
|
||||
tool.set_context(channel, chat_id, *([message_id] if name == "message" else []))
|
||||
@@ -339,6 +351,7 @@ class AgentLoop:
|
||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||
*,
|
||||
session: Session | None = None,
|
||||
channel: str = "cli",
|
||||
@@ -383,10 +396,12 @@ class AgentLoop:
|
||||
pending_msg = pending_queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
user_content = self.context._build_user_content(
|
||||
pending_msg.content,
|
||||
pending_msg.media if pending_msg.media else None,
|
||||
)
|
||||
content = pending_msg.content
|
||||
media = pending_msg.media if pending_msg.media else None
|
||||
if media:
|
||||
content, media = extract_documents(content, media)
|
||||
media = media or None
|
||||
user_content = self.context._build_user_content(content, media)
|
||||
runtime_ctx = self.context._build_runtime_context(
|
||||
pending_msg.channel,
|
||||
pending_msg.chat_id,
|
||||
@@ -414,12 +429,18 @@ class AgentLoop:
|
||||
context_block_limit=self.context_block_limit,
|
||||
provider_retry_mode=self.provider_retry_mode,
|
||||
progress_callback=on_progress,
|
||||
retry_wait_callback=on_retry_wait,
|
||||
checkpoint_callback=_checkpoint,
|
||||
injection_callback=_drain_pending,
|
||||
))
|
||||
self._last_usage = result.usage
|
||||
if result.stop_reason == "max_iterations":
|
||||
logger.warning("Max iterations ({}) reached", self.max_iterations)
|
||||
# Push final content through stream so streaming channels (e.g. Feishu)
|
||||
# update the card instead of leaving it empty.
|
||||
if on_stream and on_stream_end:
|
||||
await on_stream(result.final_content or "")
|
||||
await on_stream_end(resuming=False)
|
||||
elif result.stop_reason == "error":
|
||||
logger.error("LLM returned error: {}", (result.final_content or "")[:200])
|
||||
return result.final_content, result.tools_used, result.messages, result.stop_reason, result.had_injections
|
||||
@@ -628,13 +649,25 @@ class AgentLoop:
|
||||
session, pending = self.auto_compact.prepare_session(session, key)
|
||||
|
||||
await self.consolidator.maybe_consolidate_by_tokens(session)
|
||||
# Persist subagent follow-ups into durable history BEFORE prompt
|
||||
# assembly. ContextBuilder merges adjacent same-role messages for
|
||||
# provider compatibility, which previously caused the follow-up to
|
||||
# disappear from session.messages while still being visible to the
|
||||
# LLM via the merged prompt. See _persist_subagent_followup.
|
||||
is_subagent = msg.sender_id == "subagent"
|
||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||
self.sessions.save(session)
|
||||
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
|
||||
history = session.get_history(max_messages=0)
|
||||
current_role = "assistant" if msg.sender_id == "subagent" else "user"
|
||||
current_role = "assistant" if is_subagent else "user"
|
||||
|
||||
# Subagent content is already in `history` above; passing it again
|
||||
# as current_message would double-project it into the prompt.
|
||||
messages = self.context.build_messages(
|
||||
history=history,
|
||||
current_message=msg.content, channel=channel, chat_id=chat_id,
|
||||
current_message="" if is_subagent else msg.content,
|
||||
channel=channel,
|
||||
chat_id=chat_id,
|
||||
session_summary=pending,
|
||||
current_role=current_role,
|
||||
)
|
||||
@@ -652,6 +685,12 @@ class AgentLoop:
|
||||
content=final_content or "Background task completed.",
|
||||
)
|
||||
|
||||
# Extract document text from media at the processing boundary so all
|
||||
# channels benefit without format-specific logic in ContextBuilder.
|
||||
if msg.media:
|
||||
new_content, image_only = extract_documents(msg.content, msg.media)
|
||||
msg = dataclasses.replace(msg, content=new_content, media=image_only)
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
|
||||
@@ -701,6 +740,18 @@ class AgentLoop:
|
||||
)
|
||||
)
|
||||
|
||||
async def _on_retry_wait(content: str) -> None:
|
||||
meta = dict(msg.metadata or {})
|
||||
meta["_retry_wait"] = True
|
||||
await self.bus.publish_outbound(
|
||||
OutboundMessage(
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
content=content,
|
||||
metadata=meta,
|
||||
)
|
||||
)
|
||||
|
||||
# Persist the triggering user message immediately, before running the
|
||||
# agent loop. If the process is killed mid-turn (OOM, SIGKILL, self-
|
||||
# restart, etc.), the existing runtime_checkpoint preserves the
|
||||
@@ -719,6 +770,7 @@ class AgentLoop:
|
||||
on_progress=on_progress or _bus_progress,
|
||||
on_stream=on_stream,
|
||||
on_stream_end=on_stream_end,
|
||||
on_retry_wait=_on_retry_wait,
|
||||
session=session,
|
||||
channel=msg.channel,
|
||||
chat_id=msg.chat_id,
|
||||
@@ -845,6 +897,30 @@ class AgentLoop:
|
||||
session.messages.append(entry)
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
def _persist_subagent_followup(self, session: Session, msg: InboundMessage) -> bool:
|
||||
"""Persist subagent follow-ups before prompt assembly so history stays durable.
|
||||
|
||||
Returns True if a new entry was appended; False if the follow-up was
|
||||
deduped (same ``subagent_task_id`` already in session) or carries no
|
||||
content worth persisting.
|
||||
"""
|
||||
if not msg.content:
|
||||
return False
|
||||
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None
|
||||
if task_id and any(
|
||||
m.get("injected_event") == "subagent_result" and m.get("subagent_task_id") == task_id
|
||||
for m in session.messages
|
||||
):
|
||||
return False
|
||||
session.add_message(
|
||||
"assistant",
|
||||
msg.content,
|
||||
sender_id=msg.sender_id,
|
||||
injected_event="subagent_result",
|
||||
subagent_task_id=task_id,
|
||||
)
|
||||
return True
|
||||
|
||||
def _set_runtime_checkpoint(self, session: Session, payload: dict[str, Any]) -> None:
|
||||
"""Persist the latest in-flight turn state into session metadata."""
|
||||
session.metadata[self._RUNTIME_CHECKPOINT_KEY] = payload
|
||||
@@ -952,13 +1028,17 @@ class AgentLoop:
|
||||
session_key: str = "cli:direct",
|
||||
channel: str = "cli",
|
||||
chat_id: str = "direct",
|
||||
media: list[str] | None = None,
|
||||
on_progress: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Process a message directly and return the outbound payload."""
|
||||
await self._connect_mcp()
|
||||
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
|
||||
msg = InboundMessage(
|
||||
channel=channel, sender_id="user", chat_id=chat_id,
|
||||
content=content, media=media or [],
|
||||
)
|
||||
return await self._process_message(
|
||||
msg,
|
||||
session_key=session_key,
|
||||
|
||||
+77
-6
@@ -239,13 +239,13 @@ class MemoryStore:
|
||||
pass
|
||||
# Fallback: read last line's cursor from the JSONL file.
|
||||
last = self._read_last_entry()
|
||||
if last:
|
||||
if last and last.get("cursor"):
|
||||
return last["cursor"] + 1
|
||||
return 1
|
||||
|
||||
def read_unprocessed_history(self, since_cursor: int) -> list[dict[str, Any]]:
|
||||
"""Return history entries with cursor > *since_cursor*."""
|
||||
return [e for e in self._read_entries() if e["cursor"] > since_cursor]
|
||||
return [e for e in self._read_entries() if e.get("cursor", 0) > since_cursor]
|
||||
|
||||
def compact_history(self) -> None:
|
||||
"""Drop oldest entries if the file exceeds *max_history_entries*."""
|
||||
@@ -457,6 +457,8 @@ class Consolidator:
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
)
|
||||
if response.finish_reason == "error":
|
||||
raise RuntimeError(f"LLM returned error: {response.content}")
|
||||
summary = response.content or "[no summary]"
|
||||
self.store.append_history(summary)
|
||||
return summary
|
||||
@@ -552,6 +554,13 @@ class Consolidator:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Single source of truth for the staleness threshold used in _annotate_with_ages
|
||||
# *and* in the Phase 1 prompt template (passed as `stale_threshold_days`).
|
||||
# Keep code and prompt aligned — if you bump this, the LLM's instruction string
|
||||
# updates automatically.
|
||||
_STALE_THRESHOLD_DAYS = 14
|
||||
|
||||
|
||||
class Dream:
|
||||
"""Two-phase memory processor: analyze history.jsonl, then edit files via AgentRunner.
|
||||
|
||||
@@ -568,6 +577,7 @@ class Dream:
|
||||
max_batch_size: int = 20,
|
||||
max_iterations: int = 10,
|
||||
max_tool_result_chars: int = 16_000,
|
||||
annotate_line_ages: bool = True,
|
||||
):
|
||||
self.store = store
|
||||
self.provider = provider
|
||||
@@ -575,6 +585,10 @@ class Dream:
|
||||
self.max_batch_size = max_batch_size
|
||||
self.max_iterations = max_iterations
|
||||
self.max_tool_result_chars = max_tool_result_chars
|
||||
# Kill switch for the git-blame-based per-line age annotation in Phase 1.
|
||||
# Default True keeps the #3212 behavior; set False to feed MEMORY.md raw
|
||||
# (e.g. if a specific LLM reacts poorly to the `← Nd` suffix).
|
||||
self.annotate_line_ages = annotate_line_ages
|
||||
self._runner = AgentRunner(provider)
|
||||
self._tools = self._build_tools()
|
||||
|
||||
@@ -632,6 +646,52 @@ class Dream:
|
||||
|
||||
# -- main entry ----------------------------------------------------------
|
||||
|
||||
def _annotate_with_ages(self, content: str) -> str:
|
||||
"""Append per-line age suffixes to MEMORY.md content.
|
||||
|
||||
Each non-blank line whose age exceeds ``_STALE_THRESHOLD_DAYS`` gets a
|
||||
suffix like ``← 30d`` indicating days since last modification.
|
||||
Returns the original content unchanged if git is unavailable,
|
||||
annotate fails, or the line count doesn't match the age count
|
||||
(which can happen with an uncommitted working-tree edit — better to
|
||||
skip annotation than to tag the wrong line).
|
||||
SOUL.md and USER.md are never annotated.
|
||||
"""
|
||||
file_path = "memory/MEMORY.md"
|
||||
try:
|
||||
ages = self.store.git.line_ages(file_path)
|
||||
except Exception:
|
||||
logger.debug("line_ages failed for {}", file_path)
|
||||
return content
|
||||
if not ages:
|
||||
return content
|
||||
|
||||
had_trailing = content.endswith("\n")
|
||||
lines = content.splitlines()
|
||||
# If HEAD-blob line count disagrees with the working-tree content we
|
||||
# received, ages would be assigned to the wrong lines — skip entirely
|
||||
# and feed the LLM un-annotated content rather than misleading data.
|
||||
if len(lines) != len(ages):
|
||||
logger.debug(
|
||||
"line_ages length mismatch for {} (lines={}, ages={}); skipping annotation",
|
||||
file_path, len(lines), len(ages),
|
||||
)
|
||||
return content
|
||||
|
||||
annotated: list[str] = []
|
||||
for line, age in zip(lines, ages):
|
||||
if not line.strip():
|
||||
annotated.append(line)
|
||||
continue
|
||||
if age.age_days > _STALE_THRESHOLD_DAYS:
|
||||
annotated.append(f"{line} \u2190 {age.age_days}d")
|
||||
else:
|
||||
annotated.append(line)
|
||||
result = "\n".join(annotated)
|
||||
if had_trailing:
|
||||
result += "\n"
|
||||
return result
|
||||
|
||||
async def run(self) -> bool:
|
||||
"""Process unprocessed history entries. Returns True if work was done."""
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
@@ -652,9 +712,14 @@ class Dream:
|
||||
f"[{e['timestamp']}] {e['content']}" for e in batch
|
||||
)
|
||||
|
||||
# Current file contents
|
||||
# Current file contents + per-line age annotations (MEMORY.md only)
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
current_memory = self.store.read_memory() or "(empty)"
|
||||
raw_memory = self.store.read_memory() or "(empty)"
|
||||
current_memory = (
|
||||
self._annotate_with_ages(raw_memory)
|
||||
if self.annotate_line_ages
|
||||
else raw_memory
|
||||
)
|
||||
current_soul = self.store.read_soul() or "(empty)"
|
||||
current_user = self.store.read_user() or "(empty)"
|
||||
|
||||
@@ -676,7 +741,11 @@ class Dream:
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": render_template("agent/dream_phase1.md", strip=True),
|
||||
"content": render_template(
|
||||
"agent/dream_phase1.md",
|
||||
strip=True,
|
||||
stale_threshold_days=_STALE_THRESHOLD_DAYS,
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": phase1_prompt},
|
||||
],
|
||||
@@ -759,7 +828,9 @@ class Dream:
|
||||
# Git auto-commit (only when there are actual changes)
|
||||
if changelog and self.store.git.is_initialized():
|
||||
ts = batch[-1]["timestamp"]
|
||||
sha = self.store.git.auto_commit(f"dream: {ts}, {len(changelog)} change(s)")
|
||||
summary = f"dream: {ts}, {len(changelog)} change(s)"
|
||||
commit_msg = f"{summary}\n\n{analysis.strip()}"
|
||||
sha = self.store.git.auto_commit(commit_msg)
|
||||
if sha:
|
||||
logger.info("Dream commit: {}", sha)
|
||||
|
||||
|
||||
+20
-2
@@ -72,6 +72,7 @@ class AgentRunSpec:
|
||||
context_block_limit: int | None = None
|
||||
provider_retry_mode: str = "standard"
|
||||
progress_callback: Any | None = None
|
||||
retry_wait_callback: Any | None = None
|
||||
checkpoint_callback: Any | None = None
|
||||
injection_callback: Any | None = None
|
||||
|
||||
@@ -275,7 +276,7 @@ class AgentRunner:
|
||||
context.tool_calls = list(response.tool_calls)
|
||||
self._accumulate_usage(usage, raw_usage)
|
||||
|
||||
if response.has_tool_calls:
|
||||
if response.should_execute_tools:
|
||||
if hook.wants_streaming():
|
||||
await hook.on_stream_end(context, resuming=True)
|
||||
|
||||
@@ -365,6 +366,13 @@ class AgentRunner:
|
||||
await hook.after_iteration(context)
|
||||
continue
|
||||
|
||||
if response.has_tool_calls:
|
||||
logger.warning(
|
||||
"Ignoring tool calls under finish_reason='{}' for {}",
|
||||
response.finish_reason,
|
||||
spec.session_key or "default",
|
||||
)
|
||||
|
||||
clean = hook.finalize_content(context, response.content)
|
||||
if response.finish_reason != "error" and is_blank_text(clean):
|
||||
empty_content_retries += 1
|
||||
@@ -548,7 +556,7 @@ class AgentRunner:
|
||||
"tools": tools,
|
||||
"model": spec.model,
|
||||
"retry_mode": spec.provider_retry_mode,
|
||||
"on_retry_wait": spec.progress_callback,
|
||||
"on_retry_wait": spec.retry_wait_callback,
|
||||
}
|
||||
if spec.temperature is not None:
|
||||
kwargs["temperature"] = spec.temperature
|
||||
@@ -953,6 +961,16 @@ class AgentRunner:
|
||||
if message.get("role") == "user":
|
||||
kept = kept[i:]
|
||||
break
|
||||
else:
|
||||
# Recover nearest user message from outside the kept window;
|
||||
# GLM rejects system→assistant (error 1214). Budget is
|
||||
# intentionally exceeded — oversized beats invalid.
|
||||
for idx in range(len(non_system) - 1, -1, -1):
|
||||
if non_system[idx].get("role") == "user":
|
||||
kept = non_system[idx:]
|
||||
break
|
||||
# If no user exists at all, _enforce_role_alternation
|
||||
# will insert a synthetic one as a safety net.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
|
||||
+43
-34
@@ -6,6 +6,8 @@ import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
# Default builtin skills directory (relative to this file)
|
||||
BUILTIN_SKILLS_DIR = Path(__file__).parent.parent / "skills"
|
||||
|
||||
@@ -16,10 +18,6 @@ _STRIP_SKILL_FRONTMATTER = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
class SkillsLoader:
|
||||
"""
|
||||
Loader for agent skills.
|
||||
@@ -110,39 +108,37 @@ class SkillsLoader:
|
||||
]
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def build_skills_summary(self) -> str:
|
||||
def build_skills_summary(self, exclude: set[str] | None = None) -> str:
|
||||
"""
|
||||
Build a summary of all skills (name, description, path, availability).
|
||||
|
||||
This is used for progressive loading - the agent can read the full
|
||||
skill content using read_file when needed.
|
||||
|
||||
Args:
|
||||
exclude: Set of skill names to omit from the summary.
|
||||
|
||||
Returns:
|
||||
XML-formatted skills summary.
|
||||
Markdown-formatted skills summary.
|
||||
"""
|
||||
all_skills = self.list_skills(filter_unavailable=False)
|
||||
if not all_skills:
|
||||
return ""
|
||||
|
||||
lines: list[str] = ["<skills>"]
|
||||
lines: list[str] = []
|
||||
for entry in all_skills:
|
||||
skill_name = entry["name"]
|
||||
if exclude and skill_name in exclude:
|
||||
continue
|
||||
meta = self._get_skill_meta(skill_name)
|
||||
available = self._check_requirements(meta)
|
||||
lines.extend(
|
||||
[
|
||||
f' <skill available="{str(available).lower()}">',
|
||||
f" <name>{_escape_xml(skill_name)}</name>",
|
||||
f" <description>{_escape_xml(self._get_skill_description(skill_name))}</description>",
|
||||
f" <location>{entry['path']}</location>",
|
||||
]
|
||||
)
|
||||
if not available:
|
||||
desc = self._get_skill_description(skill_name)
|
||||
if available:
|
||||
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
|
||||
else:
|
||||
missing = self._get_missing_requirements(meta)
|
||||
if missing:
|
||||
lines.append(f" <requires>{_escape_xml(missing)}</requires>")
|
||||
lines.append(" </skill>")
|
||||
lines.append("</skills>")
|
||||
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
||||
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
||||
@@ -171,11 +167,19 @@ class SkillsLoader:
|
||||
return content[match.end():].strip()
|
||||
return content
|
||||
|
||||
def _parse_nanobot_metadata(self, raw: str) -> dict:
|
||||
"""Parse skill metadata JSON from frontmatter (supports nanobot and openclaw keys)."""
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
def _parse_nanobot_metadata(self, raw: object) -> dict:
|
||||
"""Extract nanobot/openclaw metadata from a frontmatter field.
|
||||
|
||||
``raw`` may be a dict (already parsed by yaml.safe_load) or a JSON str.
|
||||
"""
|
||||
if isinstance(raw, dict):
|
||||
data = raw
|
||||
elif isinstance(raw, str):
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
@@ -193,8 +197,8 @@ class SkillsLoader:
|
||||
|
||||
def _get_skill_meta(self, name: str) -> dict:
|
||||
"""Get nanobot metadata for a skill (cached in frontmatter)."""
|
||||
meta = self.get_skill_metadata(name) or {}
|
||||
return self._parse_nanobot_metadata(meta.get("metadata", ""))
|
||||
raw_meta = self.get_skill_metadata(name) or {}
|
||||
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
|
||||
|
||||
def get_always_skills(self) -> list[str]:
|
||||
"""Get skills marked as always=true that meet requirements."""
|
||||
@@ -203,7 +207,7 @@ class SkillsLoader:
|
||||
for entry in self.list_skills(filter_unavailable=True)
|
||||
if (meta := self.get_skill_metadata(entry["name"]) or {})
|
||||
and (
|
||||
self._parse_nanobot_metadata(meta.get("metadata", "")).get("always")
|
||||
self._parse_nanobot_metadata(meta.get("metadata")).get("always")
|
||||
or meta.get("always")
|
||||
)
|
||||
]
|
||||
@@ -224,10 +228,15 @@ class SkillsLoader:
|
||||
match = _STRIP_SKILL_FRONTMATTER.match(content)
|
||||
if not match:
|
||||
return None
|
||||
metadata: dict[str, str] = {}
|
||||
for line in match.group(1).splitlines():
|
||||
if ":" not in line:
|
||||
continue
|
||||
key, value = line.split(":", 1)
|
||||
metadata[key.strip()] = value.strip().strip('"\'')
|
||||
try:
|
||||
parsed = yaml.safe_load(match.group(1))
|
||||
except yaml.YAMLError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
# yaml.safe_load returns native types (int, bool, list, etc.);
|
||||
# keep values as-is so downstream consumers get correct types.
|
||||
metadata: dict[str, object] = {}
|
||||
for key, value in parsed.items():
|
||||
metadata[str(key)] = value
|
||||
return metadata
|
||||
|
||||
+84
-32
@@ -2,7 +2,9 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -23,12 +25,29 @@ from nanobot.config.schema import ExecToolConfig, WebToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
class _SubagentHook(AgentHook):
|
||||
"""Logging-only hook for subagent execution."""
|
||||
@dataclass(slots=True)
|
||||
class SubagentStatus:
|
||||
"""Real-time status of a running subagent."""
|
||||
|
||||
def __init__(self, task_id: str) -> None:
|
||||
task_id: str
|
||||
label: str
|
||||
task_description: str
|
||||
started_at: float # time.monotonic()
|
||||
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
|
||||
iteration: int = 0
|
||||
tool_events: list = field(default_factory=list) # [{name, status, detail}, ...]
|
||||
usage: dict = field(default_factory=dict) # token usage
|
||||
stop_reason: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class _SubagentHook(AgentHook):
|
||||
"""Hook for subagent execution — logs tool calls and updates status."""
|
||||
|
||||
def __init__(self, task_id: str, status: SubagentStatus | None = None) -> None:
|
||||
super().__init__()
|
||||
self._task_id = task_id
|
||||
self._status = status
|
||||
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
for tool_call in context.tool_calls:
|
||||
@@ -38,6 +57,15 @@ class _SubagentHook(AgentHook):
|
||||
self._task_id, tool_call.name, args_str,
|
||||
)
|
||||
|
||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||
if self._status is None:
|
||||
return
|
||||
self._status.iteration = context.iteration
|
||||
self._status.tool_events = list(context.tool_events)
|
||||
self._status.usage = dict(context.usage)
|
||||
if context.error:
|
||||
self._status.error = str(context.error)
|
||||
|
||||
|
||||
class SubagentManager:
|
||||
"""Manages background subagent execution."""
|
||||
@@ -54,8 +82,6 @@ class SubagentManager:
|
||||
restrict_to_workspace: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
):
|
||||
from nanobot.config.schema import ExecToolConfig
|
||||
|
||||
self.provider = provider
|
||||
self.workspace = workspace
|
||||
self.bus = bus
|
||||
@@ -67,6 +93,7 @@ class SubagentManager:
|
||||
self.disabled_skills = set(disabled_skills or [])
|
||||
self.runner = AgentRunner(provider)
|
||||
self._running_tasks: dict[str, asyncio.Task[None]] = {}
|
||||
self._task_statuses: dict[str, SubagentStatus] = {}
|
||||
self._session_tasks: dict[str, set[str]] = {} # session_key -> {task_id, ...}
|
||||
|
||||
async def spawn(
|
||||
@@ -82,8 +109,16 @@ class SubagentManager:
|
||||
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
|
||||
origin = {"channel": origin_channel, "chat_id": origin_chat_id}
|
||||
|
||||
status = SubagentStatus(
|
||||
task_id=task_id,
|
||||
label=display_label,
|
||||
task_description=task,
|
||||
started_at=time.monotonic(),
|
||||
)
|
||||
self._task_statuses[task_id] = status
|
||||
|
||||
bg_task = asyncio.create_task(
|
||||
self._run_subagent(task_id, task, display_label, origin)
|
||||
self._run_subagent(task_id, task, display_label, origin, status)
|
||||
)
|
||||
self._running_tasks[task_id] = bg_task
|
||||
if session_key:
|
||||
@@ -91,6 +126,7 @@ class SubagentManager:
|
||||
|
||||
def _cleanup(_: asyncio.Task) -> None:
|
||||
self._running_tasks.pop(task_id, None)
|
||||
self._task_statuses.pop(task_id, None)
|
||||
if session_key and (ids := self._session_tasks.get(session_key)):
|
||||
ids.discard(task_id)
|
||||
if not ids:
|
||||
@@ -107,10 +143,15 @@ class SubagentManager:
|
||||
task: str,
|
||||
label: str,
|
||||
origin: dict[str, str],
|
||||
status: SubagentStatus,
|
||||
) -> None:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
logger.info("Subagent [{}] starting task: {}", task_id, label)
|
||||
|
||||
async def _on_checkpoint(payload: dict) -> None:
|
||||
status.phase = payload.get("phase", status.phase)
|
||||
status.iteration = payload.get("iteration", status.iteration)
|
||||
|
||||
try:
|
||||
# Build subagent tools (no message tool, no spawn tool)
|
||||
tools = ToolRegistry()
|
||||
@@ -129,6 +170,7 @@ class SubagentManager:
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
sandbox=self.exec_config.sandbox,
|
||||
path_append=self.exec_config.path_append,
|
||||
allowed_env_keys=self.exec_config.allowed_env_keys,
|
||||
))
|
||||
if self.web_config.enable:
|
||||
tools.register(WebSearchTool(config=self.web_config.search, proxy=self.web_config.proxy))
|
||||
@@ -145,40 +187,38 @@ class SubagentManager:
|
||||
model=self.model,
|
||||
max_iterations=15,
|
||||
max_tool_result_chars=self.max_tool_result_chars,
|
||||
hook=_SubagentHook(task_id),
|
||||
hook=_SubagentHook(task_id, status),
|
||||
max_iterations_message="Task completed but no final response was generated.",
|
||||
error_message=None,
|
||||
fail_on_tool_error=True,
|
||||
checkpoint_callback=_on_checkpoint,
|
||||
))
|
||||
if result.stop_reason == "tool_error":
|
||||
await self._announce_result(
|
||||
task_id,
|
||||
label,
|
||||
task,
|
||||
self._format_partial_progress(result),
|
||||
origin,
|
||||
"error",
|
||||
)
|
||||
return
|
||||
if result.stop_reason == "error":
|
||||
await self._announce_result(
|
||||
task_id,
|
||||
label,
|
||||
task,
|
||||
result.error or "Error: subagent execution failed.",
|
||||
origin,
|
||||
"error",
|
||||
)
|
||||
return
|
||||
final_result = result.final_content or "Task completed but no final response was generated."
|
||||
status.phase = "done"
|
||||
status.stop_reason = result.stop_reason
|
||||
|
||||
logger.info("Subagent [{}] completed successfully", task_id)
|
||||
await self._announce_result(task_id, label, task, final_result, origin, "ok")
|
||||
if result.stop_reason == "tool_error":
|
||||
status.tool_events = list(result.tool_events)
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
self._format_partial_progress(result),
|
||||
origin, "error",
|
||||
)
|
||||
elif result.stop_reason == "error":
|
||||
await self._announce_result(
|
||||
task_id, label, task,
|
||||
result.error or "Error: subagent execution failed.",
|
||||
origin, "error",
|
||||
)
|
||||
else:
|
||||
final_result = result.final_content or "Task completed but no final response was generated."
|
||||
logger.info("Subagent [{}] completed successfully", task_id)
|
||||
await self._announce_result(task_id, label, task, final_result, origin, "ok")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error: {str(e)}"
|
||||
status.phase = "error"
|
||||
status.error = str(e)
|
||||
logger.error("Subagent [{}] failed: {}", task_id, e)
|
||||
await self._announce_result(task_id, label, task, error_msg, origin, "error")
|
||||
await self._announce_result(task_id, label, task, f"Error: {e}", origin, "error")
|
||||
|
||||
async def _announce_result(
|
||||
self,
|
||||
@@ -206,6 +246,10 @@ class SubagentManager:
|
||||
sender_id="subagent",
|
||||
chat_id=f"{origin['channel']}:{origin['chat_id']}",
|
||||
content=announce_content,
|
||||
metadata={
|
||||
"injected_event": "subagent_result",
|
||||
"subagent_task_id": task_id,
|
||||
},
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
@@ -262,3 +306,11 @@ class SubagentManager:
|
||||
def get_running_count(self) -> int:
|
||||
"""Return the number of currently running subagents."""
|
||||
return len(self._running_tasks)
|
||||
|
||||
def get_running_count_by_session(self, session_key: str) -> int:
|
||||
"""Return the number of currently running subagents for a session."""
|
||||
tids = self._session_tasks.get(session_key, set())
|
||||
return sum(
|
||||
1 for tid in tids
|
||||
if tid in self._running_tasks and not self._running_tasks[tid].done()
|
||||
)
|
||||
|
||||
@@ -21,9 +21,9 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
||||
"(e.g., 'weather-monitor', 'daily-standup'). Defaults to first 30 chars of message."
|
||||
),
|
||||
message=StringSchema(
|
||||
"Instruction for the agent to execute when the job triggers. "
|
||||
"Required when action='add' "
|
||||
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report')"
|
||||
"REQUIRED when action='add'. Instruction for the agent to execute when the job triggers "
|
||||
"(e.g., 'Send a reminder to WeChat: xxx' or 'Check system status and report'). "
|
||||
"Not used for action='list' or action='remove'."
|
||||
),
|
||||
every_seconds=IntegerSchema(0, description="Interval in seconds (for recurring tasks)"),
|
||||
cron_expr=StringSchema("Cron expression like '0 9 * * *' (for scheduled tasks)"),
|
||||
@@ -39,7 +39,7 @@ _CRON_PARAMETERS = tool_parameters_schema(
|
||||
description="Whether to deliver the execution result to the user channel (default true)",
|
||||
default=True,
|
||||
),
|
||||
job_id=StringSchema("Job ID (for remove)"),
|
||||
job_id=StringSchema("REQUIRED when action='remove'. Job ID to remove (obtain via action='list')."),
|
||||
required=["action"],
|
||||
description=(
|
||||
"Action-specific parameters: add requires a non-empty message plus one schedule "
|
||||
@@ -69,9 +69,7 @@ _CRON_PARAMETERS["oneOf"] = [
|
||||
]
|
||||
|
||||
|
||||
@tool_parameters(
|
||||
_CRON_PARAMETERS
|
||||
)
|
||||
@tool_parameters(_CRON_PARAMETERS)
|
||||
class CronTool(Tool):
|
||||
"""Tool to schedule reminders and recurring tasks."""
|
||||
|
||||
@@ -172,7 +170,8 @@ class CronTool(Tool):
|
||||
if not message:
|
||||
return (
|
||||
"Error: cron action='add' requires a non-empty 'message' parameter "
|
||||
"describing what to do when the job triggers. Retry including message=\"...\"."
|
||||
"describing what to do when the job triggers "
|
||||
"(e.g. the reminder text). Retry including message=\"...\"."
|
||||
)
|
||||
if not self._channel or not self._chat_id:
|
||||
return "Error: no session context (channel/chat_id)"
|
||||
|
||||
@@ -80,11 +80,14 @@ def check_read(path: str | Path) -> str | None:
|
||||
entry.mtime = current_mtime
|
||||
return None
|
||||
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
||||
# mtime unchanged - still check content hash to detect quick modifications
|
||||
if entry.content_hash and _hash_file(p) != entry.content_hash:
|
||||
return "Warning: file has been modified since last read. Re-read to verify content before editing."
|
||||
return None
|
||||
|
||||
|
||||
def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) -> bool:
|
||||
"""Return True if file was previously read with same params and mtime is unchanged."""
|
||||
"""Return True if file was previously read with same params and content is unchanged."""
|
||||
p = str(Path(path).resolve())
|
||||
entry = _state.get(p)
|
||||
if entry is None:
|
||||
@@ -97,7 +100,18 @@ def is_unchanged(path: str | Path, offset: int = 1, limit: int | None = None) ->
|
||||
current_mtime = os.path.getmtime(p)
|
||||
except OSError:
|
||||
return False
|
||||
return current_mtime == entry.mtime
|
||||
if current_mtime != entry.mtime:
|
||||
# mtime changed - check if content also changed
|
||||
current_hash = _hash_file(p)
|
||||
if current_hash != entry.content_hash:
|
||||
# Content actually changed - don't dedup
|
||||
entry.can_dedup = False
|
||||
return False
|
||||
# Content identical despite mtime change (e.g. touch) - mark as not dedupable to force full read next time
|
||||
entry.can_dedup = False
|
||||
return True
|
||||
# mtime unchanged - content must be identical
|
||||
return True
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import difflib
|
||||
import mimetypes
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -74,10 +75,23 @@ def _is_blocked_device(path: str | Path) -> bool:
|
||||
"""Check if path is a blocked device that could hang or produce infinite output."""
|
||||
import re
|
||||
raw = str(path)
|
||||
if raw in _BLOCKED_DEVICE_PATHS:
|
||||
|
||||
# Resolve symlinks to check the actual target
|
||||
try:
|
||||
resolved = str(Path(raw).resolve())
|
||||
except (OSError, ValueError):
|
||||
resolved = raw
|
||||
|
||||
if raw in _BLOCKED_DEVICE_PATHS or resolved in _BLOCKED_DEVICE_PATHS:
|
||||
return True
|
||||
if re.match(r"/proc/\d+/fd/[012]$", raw) or re.match(r"/proc/self/fd/[012]$", raw):
|
||||
return True
|
||||
if re.match(r"/proc/\d+/fd/[012]$", resolved) or re.match(r"/proc/self/fd/[012]$", resolved):
|
||||
return True
|
||||
|
||||
# Check if resolved path starts with /dev/ (covers symlinks to devices)
|
||||
if resolved.startswith("/dev/"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -164,14 +178,52 @@ class ReadFileTool(_FsTool):
|
||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||
|
||||
# Read dedup: same path + offset + limit + unchanged mtime → stub
|
||||
if file_state.is_unchanged(fp, offset=offset, limit=limit):
|
||||
return f"[File unchanged since last read: {path}]"
|
||||
# Always check for external modifications before dedup
|
||||
entry = file_state._state.get(str(fp.resolve()))
|
||||
try:
|
||||
current_mtime = os.path.getmtime(fp)
|
||||
except OSError:
|
||||
current_mtime = 0.0
|
||||
if entry and entry.can_dedup and entry.offset == offset and entry.limit == limit:
|
||||
if current_mtime != entry.mtime:
|
||||
# File was modified externally - force full read and mark as not dedupable
|
||||
entry.can_dedup = False
|
||||
file_state.record_read(fp, offset=offset, limit=limit) # Update state with new mtime
|
||||
# Continue to read full content (don't return dedup message)
|
||||
else:
|
||||
# File unchanged - return dedup message
|
||||
# But only if content is actually unchanged (not just mtime)
|
||||
current_hash = file_state._hash_file(str(fp))
|
||||
if current_hash == entry.content_hash:
|
||||
return f"[File unchanged since last read: {path}]"
|
||||
else:
|
||||
# Content changed despite same mtime - force full read
|
||||
entry.can_dedup = False
|
||||
file_state.record_read(fp, offset=offset, limit=limit)
|
||||
else:
|
||||
# No previous state or marked as not dedupable - read full content
|
||||
file_state.record_read(fp, offset=offset, limit=limit)
|
||||
# Force full read by setting can_dedup to False for this read
|
||||
if entry:
|
||||
entry.can_dedup = False
|
||||
|
||||
# Read the file content after dedup check
|
||||
raw = fp.read_bytes()
|
||||
try:
|
||||
text_content = raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Binary file - return error message
|
||||
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
return build_image_content_blocks(raw, mime, str(fp), f"(Image file: {path})")
|
||||
return f"Error: Cannot read binary file {path} (MIME: {mime or 'unknown'}). Only UTF-8 text and images are supported."
|
||||
|
||||
# Normalize CRLF -> LF before line-splitting. Primarily a Windows
|
||||
# concern (git checkouts with autocrlf, editors saving CRLF) but
|
||||
# applied on all platforms so downstream StrReplace/Grep behavior
|
||||
# is consistent regardless of where the file was written.
|
||||
text_content = text_content.replace("\r\n", "\n")
|
||||
|
||||
all_lines = text_content.splitlines()
|
||||
total = len(all_lines)
|
||||
|
||||
|
||||
@@ -14,14 +14,17 @@ class ToolRegistry:
|
||||
|
||||
def __init__(self):
|
||||
self._tools: dict[str, Tool] = {}
|
||||
self._cached_definitions: list[dict[str, Any]] | None = None
|
||||
|
||||
def register(self, tool: Tool) -> None:
|
||||
"""Register a tool."""
|
||||
self._tools[tool.name] = tool
|
||||
self._cached_definitions = None
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""Unregister a tool by name."""
|
||||
self._tools.pop(name, None)
|
||||
self._cached_definitions = None
|
||||
|
||||
def get(self, name: str) -> Tool | None:
|
||||
"""Get a tool by name."""
|
||||
@@ -46,8 +49,12 @@ class ToolRegistry:
|
||||
"""Get tool definitions with stable ordering for cache-friendly prompts.
|
||||
|
||||
Built-in tools are sorted first as a stable prefix, then MCP tools are
|
||||
sorted and appended.
|
||||
sorted and appended. The result is cached until the next
|
||||
register/unregister call.
|
||||
"""
|
||||
if self._cached_definitions is not None:
|
||||
return self._cached_definitions
|
||||
|
||||
definitions = [tool.to_schema() for tool in self._tools.values()]
|
||||
builtins: list[dict[str, Any]] = []
|
||||
mcp_tools: list[dict[str, Any]] = []
|
||||
@@ -60,7 +67,8 @@ class ToolRegistry:
|
||||
|
||||
builtins.sort(key=self._schema_name)
|
||||
mcp_tools.sort(key=self._schema_name)
|
||||
return builtins + mcp_tools
|
||||
self._cached_definitions = builtins + mcp_tools
|
||||
return self._cached_definitions
|
||||
|
||||
def prepare_call(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""MyTool: runtime state inspection and configuration for the agent loop."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.subagent import SubagentStatus
|
||||
from nanobot.agent.tools.base import Tool
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
|
||||
|
||||
def _has_real_attr(obj: Any, key: str) -> bool:
|
||||
"""Check if obj has a real (explicitly set) attribute, not auto-generated by mock."""
|
||||
if isinstance(obj, dict):
|
||||
return key in obj
|
||||
d = getattr(obj, "__dict__", None)
|
||||
if d is not None and key in d:
|
||||
return True
|
||||
for cls in type(obj).__mro__:
|
||||
if key in cls.__dict__:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class MyTool(Tool):
|
||||
"""Check and set the agent loop's runtime configuration."""
|
||||
|
||||
BLOCKED = frozenset({
|
||||
# Core infrastructure
|
||||
"bus", "provider", "_running", "tools",
|
||||
# Config management
|
||||
"_runtime_vars",
|
||||
# Subsystems
|
||||
"runner", "sessions", "consolidator",
|
||||
"dream", "auto_compact", "context", "commands",
|
||||
# Sensitive runtime state (credentials, message routing, task tracking)
|
||||
"_mcp_servers", "_mcp_stacks", "_pending_queues",
|
||||
"_session_locks", "_active_tasks", "_background_tasks",
|
||||
# Security boundaries (inspect + modify both blocked)
|
||||
"restrict_to_workspace", "channels_config",
|
||||
"_concurrency_gate", "_unified_session", "_extra_hooks",
|
||||
})
|
||||
|
||||
READ_ONLY = frozenset({
|
||||
"subagents", # observable but replacing it would break the system
|
||||
"_current_iteration", # updated by runner only
|
||||
"exec_config", # inspect allowed (e.g. check sandbox), modify blocked
|
||||
"web_config", # inspect allowed (e.g. check enable), modify blocked
|
||||
})
|
||||
|
||||
_DENIED_ATTRS = frozenset({
|
||||
"__class__", "__dict__", "__bases__", "__subclasses__", "__mro__",
|
||||
"__init__", "__new__", "__reduce__", "__getstate__", "__setstate__",
|
||||
"__del__", "__call__", "__getattr__", "__setattr__", "__delattr__",
|
||||
"__code__", "__globals__", "func_globals", "func_code",
|
||||
"__wrapped__", "__closure__",
|
||||
})
|
||||
|
||||
# Sub-field names that are sensitive regardless of parent path
|
||||
_SENSITIVE_NAMES = frozenset({
|
||||
"api_key", "secret", "password", "token", "credential",
|
||||
"private_key", "access_token", "refresh_token", "auth",
|
||||
})
|
||||
|
||||
@classmethod
|
||||
def _is_sensitive_field_name(cls, name: str) -> bool:
|
||||
lowered = name.lower()
|
||||
return lowered in cls._SENSITIVE_NAMES or any(
|
||||
part in cls._SENSITIVE_NAMES for part in lowered.split("_")
|
||||
)
|
||||
|
||||
RESTRICTED: dict[str, dict[str, Any]] = {
|
||||
"max_iterations": {"type": int, "min": 1, "max": 100},
|
||||
"context_window_tokens": {"type": int, "min": 4096, "max": 1_000_000},
|
||||
"model": {"type": str, "min_len": 1},
|
||||
}
|
||||
|
||||
_MAX_RUNTIME_KEYS = 64
|
||||
|
||||
def __init__(self, loop: AgentLoop, modify_allowed: bool = True) -> None:
|
||||
self._loop = loop
|
||||
self._modify_allowed = modify_allowed
|
||||
self._channel = ""
|
||||
self._chat_id = ""
|
||||
|
||||
def __deepcopy__(self, memo: dict[int, Any]) -> MyTool:
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
result._loop = self._loop
|
||||
result._modify_allowed = self._modify_allowed
|
||||
result._channel = self._channel
|
||||
result._chat_id = self._chat_id
|
||||
return result
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
self._channel = channel
|
||||
self._chat_id = chat_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "my"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
base = (
|
||||
"Check and set your own runtime state.\n"
|
||||
"Actions: check, set.\n"
|
||||
"- check (no key): full config overview — start here.\n"
|
||||
"- check (key): drill into a value. Dot-paths allowed "
|
||||
"(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n"
|
||||
"- set (key, value): change config or store notes in your scratchpad. "
|
||||
"Scratchpad keys persist across turns but not restarts.\n"
|
||||
"Key values: _current_iteration (current progress), "
|
||||
"max_iterations - _current_iteration = remaining iterations.\n"
|
||||
"Note: web_config and exec_config are readable but read-only.\n"
|
||||
"\n"
|
||||
"When to use:\n"
|
||||
"- User asks about your model, settings, or token usage → check that key.\n"
|
||||
"- A tool fails or behaves unexpectedly → check the related config to diagnose.\n"
|
||||
"- User asks you to remember a preference for this session → set to store it in your scratchpad.\n"
|
||||
"- About to start a large task → check context_window_tokens and max_iterations first."
|
||||
)
|
||||
if not self._modify_allowed:
|
||||
base += "\nREAD-ONLY MODE: set is disabled."
|
||||
else:
|
||||
base += (
|
||||
"\nIMPORTANT: Before setting state, predict the potential impact. "
|
||||
"If the operation could cause crashes or instability "
|
||||
"(e.g. changing model), warn the user first."
|
||||
)
|
||||
return base
|
||||
|
||||
@property
|
||||
def parameters(self) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["check", "set"],
|
||||
"description": "Action to perform",
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "Dot-path for check/set. Examples: 'max_iterations', 'workspace', 'provider_retry_mode'. "
|
||||
"For check without key, shows all config values.",
|
||||
},
|
||||
"value": {"description": "New value (for set). Type must match target (int for max_iterations/context_window_tokens, str for model)."},
|
||||
},
|
||||
"required": ["action"],
|
||||
}
|
||||
|
||||
def _audit(self, action: str, detail: str) -> None:
|
||||
session = f"{self._channel}:{self._chat_id}" if self._channel else "unknown"
|
||||
logger.info("self.{} | {} | session:{}", action, detail, session)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Path resolution
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
|
||||
parts = path.split(".")
|
||||
obj = self._loop
|
||||
for part in parts:
|
||||
if part in self._DENIED_ATTRS or part.startswith("__"):
|
||||
return None, f"'{part}' is not accessible"
|
||||
if part in self.BLOCKED:
|
||||
return None, f"'{part}' is not accessible"
|
||||
if part.lower() in self._SENSITIVE_NAMES:
|
||||
return None, f"'{part}' is not accessible"
|
||||
try:
|
||||
if isinstance(obj, dict):
|
||||
if part in obj:
|
||||
obj = obj[part]
|
||||
else:
|
||||
return None, f"'{part}' not found in dict"
|
||||
else:
|
||||
obj = getattr(obj, part)
|
||||
except (KeyError, AttributeError) as e:
|
||||
return None, f"'{part}' not found: {e}"
|
||||
return obj, None
|
||||
|
||||
@staticmethod
|
||||
def _validate_key(key: str | None, label: str = "key") -> str | None:
|
||||
if not key or not key.strip():
|
||||
return f"Error: '{label}' cannot be empty or whitespace"
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Smart formatting
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _format_status(st: SubagentStatus, indent: str = " ") -> str:
|
||||
elapsed = time.monotonic() - st.started_at
|
||||
tool_summary = ", ".join(
|
||||
f"{e.get('name', '?')}({e.get('status', '?')})" for e in st.tool_events[-5:]
|
||||
) or "none"
|
||||
lines = [
|
||||
f"{indent}phase: {st.phase}, iteration: {st.iteration}, elapsed: {elapsed:.1f}s",
|
||||
f"{indent}tools: {tool_summary}",
|
||||
f"{indent}usage: {st.usage or 'n/a'}",
|
||||
]
|
||||
if st.error:
|
||||
lines.append(f"{indent}error: {st.error}")
|
||||
if st.stop_reason:
|
||||
lines.append(f"{indent}stop_reason: {st.stop_reason}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _format_value(val: Any, key: str = "") -> str:
|
||||
if isinstance(val, SubagentStatus):
|
||||
header = f"Subagent [{val.task_id}] '{val.label}'"
|
||||
detail = MyTool._format_status(val, " ")
|
||||
return f"{header}\n task: {val.task_description}\n{detail}"
|
||||
# SubagentManager: delegate to its _task_statuses dict
|
||||
if hasattr(val, "_task_statuses") and isinstance(val._task_statuses, dict):
|
||||
return MyTool._format_value(val._task_statuses, key)
|
||||
if isinstance(val, dict) and val and isinstance(next(iter(val.values())), SubagentStatus):
|
||||
prefix = f"{key}: " if key else ""
|
||||
lines = [f"{prefix}{len(val)} subagent(s):"]
|
||||
for tid, st in val.items():
|
||||
detail = MyTool._format_status(st, " ")
|
||||
lines.append(f" [{tid}] '{st.label}'\n{detail}")
|
||||
return "\n".join(lines)
|
||||
if hasattr(val, "tool_names"):
|
||||
return f"tools: {len(val.tool_names)} registered — {val.tool_names}"
|
||||
# Scalar types — repr is fine
|
||||
if isinstance(val, (str, int, float, bool, type(None))):
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Dict — small: show content; large: show keys for dot-path navigation
|
||||
if isinstance(val, dict):
|
||||
ks = list(val.keys())
|
||||
if not ks:
|
||||
return f"{key}: {{}}" if key else "{}"
|
||||
if len(ks) <= 5:
|
||||
r = repr(val)
|
||||
if len(r) <= 200:
|
||||
return f"{key}: {r}" if key else r
|
||||
preview = ", ".join(str(k) for k in ks[:15])
|
||||
suffix = ", ..." if len(ks) > 15 else ""
|
||||
return f"{key}: {{{preview}{suffix}}}" if key else f"{{{preview}{suffix}}}"
|
||||
# List/tuple — count for large, repr for small
|
||||
if isinstance(val, (list, tuple)):
|
||||
if len(val) > 20:
|
||||
return f"{key}: [{len(val)} items]" if key else f"[{len(val)} items]"
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
# Complex object — small Pydantic models: show values; others: show field names for navigation
|
||||
cls_name = type(val).__name__
|
||||
model_fields = getattr(type(val), "model_fields", None)
|
||||
if model_fields:
|
||||
fields = list(model_fields.keys())
|
||||
if len(fields) <= 8:
|
||||
# Small config objects: show field=value pairs
|
||||
pairs = []
|
||||
for f in fields:
|
||||
fv = getattr(val, f, "?")
|
||||
if MyTool._is_sensitive_field_name(f):
|
||||
continue
|
||||
if isinstance(fv, (str, int, float, bool, type(None))):
|
||||
pairs.append(f"{f}={fv!r}")
|
||||
else:
|
||||
pairs.append(f"{f}=<{type(fv).__name__}>")
|
||||
preview = ", ".join(pairs)
|
||||
return f"{key}: {preview}" if key else preview
|
||||
else:
|
||||
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")]
|
||||
if fields:
|
||||
preview = ", ".join(str(f) for f in fields[:20])
|
||||
suffix = ", ..." if len(fields) > 20 else ""
|
||||
return f"{key}: <{cls_name}> [{preview}{suffix}]" if key else f"<{cls_name}> [{preview}{suffix}]"
|
||||
r = repr(val)
|
||||
return f"{key}: {r}" if key else r
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Action dispatch
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
action: str,
|
||||
key: str | None = None,
|
||||
value: Any = None,
|
||||
**_kwargs: Any,
|
||||
) -> str:
|
||||
if action in ("inspect", "check"):
|
||||
return self._inspect(key)
|
||||
if not self._modify_allowed:
|
||||
return "Error: set is disabled (tools.my.allow_set is false)"
|
||||
if action in ("modify", "set"):
|
||||
return self._modify(key, value)
|
||||
return f"Unknown action: {action}"
|
||||
|
||||
# -- inspect --
|
||||
|
||||
def _inspect(self, key: str | None) -> str:
|
||||
if not key:
|
||||
return self._inspect_all()
|
||||
top = key.split(".")[0]
|
||||
if top in self._DENIED_ATTRS or top.startswith("__"):
|
||||
return f"Error: '{top}' is not accessible"
|
||||
obj, err = self._resolve_path(key)
|
||||
if err:
|
||||
# "scratchpad" alias for _runtime_vars
|
||||
if key == "scratchpad":
|
||||
rv = self._loop._runtime_vars
|
||||
return self._format_value(rv, "scratchpad") if rv else "scratchpad is empty"
|
||||
# Fallback: check _runtime_vars for simple keys stored by modify
|
||||
if "." not in key and key in self._loop._runtime_vars:
|
||||
return self._format_value(self._loop._runtime_vars[key], key)
|
||||
return f"Error: {err}"
|
||||
# Guard against mock auto-generated attributes
|
||||
if "." not in key and not _has_real_attr(self._loop, key):
|
||||
if key in self._loop._runtime_vars:
|
||||
return self._format_value(self._loop._runtime_vars[key], key)
|
||||
return f"Error: '{key}' not found"
|
||||
return self._format_value(obj, key)
|
||||
|
||||
def _inspect_all(self) -> str:
|
||||
loop = self._loop
|
||||
parts: list[str] = []
|
||||
# RESTRICTED keys
|
||||
for k in self.RESTRICTED:
|
||||
parts.append(self._format_value(getattr(loop, k, None), k))
|
||||
# Other useful top-level keys shown in description
|
||||
for k in ("workspace", "provider_retry_mode", "max_tool_result_chars", "_current_iteration", "web_config", "exec_config", "subagents"):
|
||||
if _has_real_attr(loop, k):
|
||||
parts.append(self._format_value(getattr(loop, k, None), k))
|
||||
# Token usage
|
||||
usage = loop._last_usage
|
||||
if usage:
|
||||
parts.append(self._format_value(usage, "_last_usage"))
|
||||
rv = loop._runtime_vars
|
||||
if rv:
|
||||
parts.append(self._format_value(rv, "scratchpad"))
|
||||
return "\n".join(parts)
|
||||
|
||||
# -- modify --
|
||||
|
||||
def _modify(self, key: str | None, value: Any) -> str:
|
||||
if err := self._validate_key(key):
|
||||
return err
|
||||
top = key.split(".")[0]
|
||||
if top in self.BLOCKED or top in self._DENIED_ATTRS or top.startswith("__") or top.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED {key}")
|
||||
return f"Error: '{key}' is protected and cannot be modified"
|
||||
if top in self.READ_ONLY:
|
||||
self._audit("modify", f"READ_ONLY {key}")
|
||||
return f"Error: '{key}' is read-only and cannot be modified"
|
||||
if "." in key:
|
||||
parent_path, leaf = key.rsplit(".", 1)
|
||||
if leaf in self._DENIED_ATTRS or leaf.startswith("__"):
|
||||
self._audit("modify", f"BLOCKED leaf '{leaf}'")
|
||||
return f"Error: '{leaf}' is not accessible"
|
||||
if leaf.lower() in self._SENSITIVE_NAMES:
|
||||
self._audit("modify", f"BLOCKED sensitive leaf '{leaf}'")
|
||||
return f"Error: '{leaf}' is not accessible"
|
||||
parent, err = self._resolve_path(parent_path)
|
||||
if err:
|
||||
return f"Error: {err}"
|
||||
if isinstance(parent, dict):
|
||||
parent[leaf] = value
|
||||
else:
|
||||
setattr(parent, leaf, value)
|
||||
self._audit("modify", f"{key} = {value!r}")
|
||||
return f"Set {key} = {value!r}"
|
||||
if key in self.RESTRICTED:
|
||||
return self._modify_restricted(key, value)
|
||||
return self._modify_free(key, value)
|
||||
|
||||
def _modify_restricted(self, key: str, value: Any) -> str:
|
||||
spec = self.RESTRICTED[key]
|
||||
expected = spec["type"]
|
||||
if expected is int and isinstance(value, bool):
|
||||
return f"Error: '{key}' must be {expected.__name__}, got bool"
|
||||
if not isinstance(value, expected):
|
||||
try:
|
||||
value = expected(value)
|
||||
except (ValueError, TypeError):
|
||||
return f"Error: '{key}' must be {expected.__name__}, got {type(value).__name__}"
|
||||
old = getattr(self._loop, key)
|
||||
if "min" in spec and value < spec["min"]:
|
||||
return f"Error: '{key}' must be >= {spec['min']}"
|
||||
if "max" in spec and value > spec["max"]:
|
||||
return f"Error: '{key}' must be <= {spec['max']}"
|
||||
if "min_len" in spec and len(str(value)) < spec["min_len"]:
|
||||
return f"Error: '{key}' must be at least {spec['min_len']} characters"
|
||||
setattr(self._loop, key, value)
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
|
||||
def _modify_free(self, key: str, value: Any) -> str:
|
||||
if _has_real_attr(self._loop, key):
|
||||
old = getattr(self._loop, key)
|
||||
if isinstance(old, (str, int, float, bool)):
|
||||
old_t, new_t = type(old), type(value)
|
||||
if old_t is float and new_t is int:
|
||||
pass # int → float coercion allowed
|
||||
elif old_t is not new_t:
|
||||
self._audit(
|
||||
"modify",
|
||||
f"REJECTED type mismatch {key}: expects {old_t.__name__}, got {new_t.__name__}",
|
||||
)
|
||||
return f"Error: '{key}' expects {old_t.__name__}, got {new_t.__name__}"
|
||||
setattr(self._loop, key, value)
|
||||
self._audit("modify", f"{key}: {old!r} -> {value!r}")
|
||||
return f"Set {key} = {value!r} (was {old!r})"
|
||||
if callable(value):
|
||||
self._audit("modify", f"REJECTED callable {key}")
|
||||
return "Error: cannot store callable values"
|
||||
err = self._validate_json_safe(value)
|
||||
if err:
|
||||
self._audit("modify", f"REJECTED {key}: {err}")
|
||||
return f"Error: {err}"
|
||||
if key not in self._loop._runtime_vars and len(self._loop._runtime_vars) >= self._MAX_RUNTIME_KEYS:
|
||||
self._audit("modify", f"REJECTED {key}: max keys ({self._MAX_RUNTIME_KEYS}) reached")
|
||||
return f"Error: scratchpad is full (max {self._MAX_RUNTIME_KEYS} keys). Remove unused keys first."
|
||||
old = self._loop._runtime_vars.get(key)
|
||||
self._loop._runtime_vars[key] = value
|
||||
self._audit("modify", f"scratchpad.{key}: {old!r} -> {value!r}")
|
||||
return f"Set scratchpad.{key} = {value!r}"
|
||||
|
||||
@classmethod
|
||||
def _validate_json_safe(cls, value: Any, depth: int = 0) -> str | None:
|
||||
if depth > 10:
|
||||
return "value nesting too deep (max 10 levels)"
|
||||
if isinstance(value, (str, int, float, bool, type(None))):
|
||||
return None
|
||||
if isinstance(value, list):
|
||||
for i, item in enumerate(value):
|
||||
if err := cls._validate_json_safe(item, depth + 1):
|
||||
return f"list[{i}] contains {err}"
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
if not isinstance(k, str):
|
||||
return f"dict key must be str, got {type(k).__name__}"
|
||||
if err := cls._validate_json_safe(v, depth + 1):
|
||||
return f"dict key '{k}' contains {err}"
|
||||
return None
|
||||
return f"unsupported type {type(value).__name__}"
|
||||
@@ -96,10 +96,37 @@ class WebSearchTool(Tool):
|
||||
self.config = config if config is not None else WebSearchConfig()
|
||||
self.proxy = proxy
|
||||
|
||||
def _effective_provider(self) -> str:
|
||||
"""Resolve the backend that execute() will actually use."""
|
||||
provider = self.config.provider.strip().lower() or "brave"
|
||||
if provider == "duckduckgo":
|
||||
return "duckduckgo"
|
||||
if provider == "brave":
|
||||
api_key = self.config.api_key or os.environ.get("BRAVE_API_KEY", "")
|
||||
return "brave" if api_key else "duckduckgo"
|
||||
if provider == "tavily":
|
||||
api_key = self.config.api_key or os.environ.get("TAVILY_API_KEY", "")
|
||||
return "tavily" if api_key else "duckduckgo"
|
||||
if provider == "searxng":
|
||||
base_url = (self.config.base_url or os.environ.get("SEARXNG_BASE_URL", "")).strip()
|
||||
return "searxng" if base_url else "duckduckgo"
|
||||
if provider == "jina":
|
||||
api_key = self.config.api_key or os.environ.get("JINA_API_KEY", "")
|
||||
return "jina" if api_key else "duckduckgo"
|
||||
if provider == "kagi":
|
||||
api_key = self.config.api_key or os.environ.get("KAGI_API_KEY", "")
|
||||
return "kagi" if api_key else "duckduckgo"
|
||||
return provider
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def exclusive(self) -> bool:
|
||||
"""DuckDuckGo searches are serialized because ddgs is not concurrency-safe."""
|
||||
return self._effective_provider() == "duckduckgo"
|
||||
|
||||
async def execute(self, query: str, count: int | None = None, **kwargs: Any) -> str:
|
||||
provider = self.config.provider.strip().lower() or "brave"
|
||||
n = min(max(count or self.config.max_results, 1), 10)
|
||||
|
||||
+253
-51
@@ -7,15 +7,30 @@ All requests route to a single persistent API session.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json as _json
|
||||
import mimetypes
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_media_dir
|
||||
from nanobot.utils.helpers import safe_filename
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
_DATA_URL_RE = re.compile(r"^data:([^;]+);base64,(.+)$", re.DOTALL)
|
||||
|
||||
|
||||
class _FileSizeExceeded(Exception):
|
||||
"""Raised when an uploaded file exceeds the size limit."""
|
||||
|
||||
|
||||
API_SESSION_KEY = "api:default"
|
||||
API_CHAT_ID = "default"
|
||||
|
||||
@@ -24,6 +39,7 @@ API_CHAT_ID = "default"
|
||||
# Response helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _error_json(status: int, message: str, err_type: str = "invalid_request_error") -> web.Response:
|
||||
return web.json_response(
|
||||
{"error": {"message": message, "type": err_type, "code": status}},
|
||||
@@ -56,50 +72,235 @@ def _response_text(value: Any) -> str:
|
||||
return str(getattr(value, "content") or "")
|
||||
return str(value)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSE helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sse_chunk(delta: str, model: str, chunk_id: str, finish_reason: str | None = None) -> bytes:
|
||||
"""Format a single OpenAI-compatible SSE chunk."""
|
||||
payload = {
|
||||
"id": chunk_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"content": delta} if delta else {},
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
}
|
||||
return f"data: {_json.dumps(payload)}\n\n".encode()
|
||||
|
||||
|
||||
_SSE_DONE = b"data: [DONE]\n\n"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Upload helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _save_base64_data_url(data_url: str, media_dir: Path) -> str | None:
|
||||
"""Decode a data:...;base64,... URL and save to disk."""
|
||||
m = _DATA_URL_RE.match(data_url)
|
||||
if not m:
|
||||
return None
|
||||
mime_type, b64_payload = m.group(1), m.group(2)
|
||||
try:
|
||||
raw = base64.b64decode(b64_payload)
|
||||
except Exception:
|
||||
return None
|
||||
if len(raw) > MAX_FILE_SIZE:
|
||||
raise _FileSizeExceeded(f"File exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit")
|
||||
ext = mimetypes.guess_extension(mime_type) or ".bin"
|
||||
filename = f"{uuid.uuid4().hex[:12]}{ext}"
|
||||
dest = media_dir / safe_filename(filename)
|
||||
dest.write_bytes(raw)
|
||||
return str(dest)
|
||||
|
||||
|
||||
def _parse_json_content(body: dict) -> tuple[str, list[str]]:
|
||||
"""Parse JSON request body. Returns (text, media_paths)."""
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or len(messages) != 1:
|
||||
raise ValueError("Only a single user message is supported")
|
||||
message = messages[0]
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
raise ValueError("Only a single user message is supported")
|
||||
|
||||
user_content = message.get("content", "")
|
||||
media_dir = get_media_dir("api")
|
||||
media_paths: list[str] = []
|
||||
|
||||
if isinstance(user_content, list):
|
||||
text_parts: list[str] = []
|
||||
for part in user_content:
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "text":
|
||||
text_parts.append(part.get("text", ""))
|
||||
elif part.get("type") == "image_url":
|
||||
url = part.get("image_url", {}).get("url", "")
|
||||
if url.startswith("data:"):
|
||||
saved = _save_base64_data_url(url, media_dir)
|
||||
if saved:
|
||||
media_paths.append(saved)
|
||||
elif url:
|
||||
raise ValueError(
|
||||
"Remote image URLs are not supported. "
|
||||
"Use base64 data URLs or upload files via multipart/form-data."
|
||||
)
|
||||
text = " ".join(text_parts)
|
||||
elif isinstance(user_content, str):
|
||||
text = user_content
|
||||
else:
|
||||
raise ValueError("Invalid content format")
|
||||
|
||||
return text, media_paths
|
||||
|
||||
|
||||
async def _parse_multipart(request: web.Request) -> tuple[str, list[str], str | None, str | None]:
|
||||
"""Parse multipart/form-data. Returns (text, media_paths, session_id, model)."""
|
||||
media_dir = get_media_dir("api")
|
||||
reader = await request.multipart()
|
||||
text = ""
|
||||
session_id = None
|
||||
model = None
|
||||
media_paths: list[str] = []
|
||||
|
||||
while True:
|
||||
part = await reader.next()
|
||||
if part is None:
|
||||
break
|
||||
if part.name == "message":
|
||||
text = (await part.read()).decode("utf-8")
|
||||
elif part.name == "session_id":
|
||||
session_id = (await part.read()).decode("utf-8").strip()
|
||||
elif part.name == "model":
|
||||
model = (await part.read()).decode("utf-8").strip()
|
||||
elif part.name == "files":
|
||||
raw = await part.read()
|
||||
if len(raw) > MAX_FILE_SIZE:
|
||||
raise _FileSizeExceeded(
|
||||
f"File '{part.filename}' exceeds {MAX_FILE_SIZE // (1024 * 1024)}MB limit"
|
||||
)
|
||||
base = safe_filename(part.filename or "upload.bin")
|
||||
filename = f"{uuid.uuid4().hex[:12]}_{base}"
|
||||
dest = media_dir / filename
|
||||
dest.write_bytes(raw)
|
||||
media_paths.append(str(dest))
|
||||
|
||||
if not text:
|
||||
text = "请分析上传的文件"
|
||||
|
||||
return text, media_paths, session_id, model
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
"""POST /v1/chat/completions"""
|
||||
|
||||
# --- Parse body ---
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _error_json(400, "Invalid JSON body")
|
||||
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or len(messages) != 1:
|
||||
return _error_json(400, "Only a single user message is supported")
|
||||
|
||||
# Stream not yet supported
|
||||
if body.get("stream", False):
|
||||
return _error_json(400, "stream=true is not supported yet. Set stream=false or omit it.")
|
||||
|
||||
message = messages[0]
|
||||
if not isinstance(message, dict) or message.get("role") != "user":
|
||||
return _error_json(400, "Only a single user message is supported")
|
||||
user_content = message.get("content", "")
|
||||
if isinstance(user_content, list):
|
||||
# Multi-modal content array — extract text parts
|
||||
user_content = " ".join(
|
||||
part.get("text", "") for part in user_content if part.get("type") == "text"
|
||||
)
|
||||
"""POST /v1/chat/completions — supports JSON and multipart/form-data."""
|
||||
content_type = request.content_type or ""
|
||||
if not isinstance(content_type, str):
|
||||
content_type = ""
|
||||
|
||||
agent_loop = request.app["agent_loop"]
|
||||
timeout_s: float = request.app.get("request_timeout", 120.0)
|
||||
model_name: str = request.app.get("model_name", "nanobot")
|
||||
if (requested_model := body.get("model")) and requested_model != model_name:
|
||||
|
||||
stream = False
|
||||
try:
|
||||
if content_type.startswith("multipart/"):
|
||||
text, media_paths, session_id, requested_model = await _parse_multipart(request)
|
||||
else:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return _error_json(400, "Invalid JSON body")
|
||||
stream = body.get("stream", False)
|
||||
requested_model = body.get("model")
|
||||
text, media_paths = _parse_json_content(body)
|
||||
session_id = body.get("session_id")
|
||||
except ValueError as e:
|
||||
return _error_json(400, str(e))
|
||||
except _FileSizeExceeded as e:
|
||||
return _error_json(413, str(e), err_type="invalid_request_error")
|
||||
except Exception:
|
||||
logger.exception("Error parsing upload")
|
||||
return _error_json(413, "File too large or invalid upload")
|
||||
|
||||
if requested_model and requested_model != model_name:
|
||||
return _error_json(400, f"Only configured model '{model_name}' is available")
|
||||
|
||||
session_key = f"api:{body['session_id']}" if body.get("session_id") else API_SESSION_KEY
|
||||
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
|
||||
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"]
|
||||
session_lock = session_locks.setdefault(session_key, asyncio.Lock())
|
||||
|
||||
logger.info("API request session_key={} content={}", session_key, user_content[:80])
|
||||
logger.info(
|
||||
"API request session_key={} media={} text={} stream={}",
|
||||
session_key, len(media_paths), text[:80], stream,
|
||||
)
|
||||
# -- streaming path --
|
||||
if stream:
|
||||
resp = web.StreamResponse()
|
||||
resp.content_type = "text/event-stream"
|
||||
resp.headers["Cache-Control"] = "no-cache"
|
||||
resp.headers["Connection"] = "keep-alive"
|
||||
resp.enable_compression()
|
||||
await resp.prepare(request)
|
||||
|
||||
chunk_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
queue: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
stream_failed = False
|
||||
|
||||
async def _on_stream(token: str) -> None:
|
||||
await queue.put(token)
|
||||
|
||||
async def _on_stream_end(*_a: Any, **_kw: Any) -> None:
|
||||
await queue.put(None)
|
||||
|
||||
async def _run() -> None:
|
||||
nonlocal stream_failed
|
||||
try:
|
||||
async with session_lock:
|
||||
await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
on_stream=_on_stream,
|
||||
on_stream_end=_on_stream_end,
|
||||
),
|
||||
timeout=timeout_s,
|
||||
)
|
||||
except Exception:
|
||||
stream_failed = True
|
||||
logger.exception("Streaming error for session {}", session_key)
|
||||
await queue.put(None)
|
||||
|
||||
task = asyncio.create_task(_run())
|
||||
try:
|
||||
while True:
|
||||
token = await queue.get()
|
||||
if token is None:
|
||||
break
|
||||
await resp.write(_sse_chunk(token, model_name, chunk_id))
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
if not stream_failed:
|
||||
await resp.write(_sse_chunk("", model_name, chunk_id, finish_reason="stop"))
|
||||
await resp.write(_SSE_DONE)
|
||||
return resp
|
||||
|
||||
# -- non-streaming path (original logic) --
|
||||
_FALLBACK = EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
try:
|
||||
@@ -107,7 +308,8 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=user_content,
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
@@ -117,13 +319,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
response_text = _response_text(response)
|
||||
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning(
|
||||
"Empty response for session {}, retrying",
|
||||
session_key,
|
||||
)
|
||||
logger.warning("Empty response for session {}, retrying", session_key)
|
||||
retry_response = await asyncio.wait_for(
|
||||
agent_loop.process_direct(
|
||||
content=user_content,
|
||||
content=text,
|
||||
media=media_paths if media_paths else None,
|
||||
session_key=session_key,
|
||||
channel="api",
|
||||
chat_id=API_CHAT_ID,
|
||||
@@ -132,10 +332,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
)
|
||||
response_text = _response_text(retry_response)
|
||||
if not response_text or not response_text.strip():
|
||||
logger.warning(
|
||||
"Empty response after retry for session {}, using fallback",
|
||||
session_key,
|
||||
)
|
||||
logger.warning("Empty response after retry, using fallback")
|
||||
response_text = _FALLBACK
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
@@ -153,17 +350,19 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
"""GET /v1/models"""
|
||||
model_name = request.app.get("model_name", "nanobot")
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": model_name,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": "nanobot",
|
||||
}
|
||||
],
|
||||
})
|
||||
return web.json_response(
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": model_name,
|
||||
"object": "model",
|
||||
"created": 0,
|
||||
"owned_by": "nanobot",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_health(request: web.Request) -> web.Response:
|
||||
@@ -175,7 +374,10 @@ async def handle_health(request: web.Request) -> web.Response:
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0) -> web.Application:
|
||||
|
||||
def create_app(
|
||||
agent_loop, model_name: str = "nanobot", request_timeout: float = 120.0
|
||||
) -> web.Application:
|
||||
"""Create the aiohttp application.
|
||||
|
||||
Args:
|
||||
@@ -183,7 +385,7 @@ def create_app(agent_loop, model_name: str = "nanobot", request_timeout: float =
|
||||
model_name: Model name reported in responses.
|
||||
request_timeout: Per-request timeout in seconds.
|
||||
"""
|
||||
app = web.Application()
|
||||
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
|
||||
app["agent_loop"] = agent_loop
|
||||
app["model_name"] = model_name
|
||||
app["request_timeout"] = request_timeout
|
||||
|
||||
@@ -24,6 +24,7 @@ class BaseChannel(ABC):
|
||||
display_name: str = "Base"
|
||||
transcription_provider: str = "groq"
|
||||
transcription_api_key: str = ""
|
||||
transcription_api_base: str = ""
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
"""
|
||||
@@ -44,10 +45,16 @@ class BaseChannel(ABC):
|
||||
try:
|
||||
if self.transcription_provider == "openai":
|
||||
from nanobot.providers.transcription import OpenAITranscriptionProvider
|
||||
provider = OpenAITranscriptionProvider(api_key=self.transcription_api_key)
|
||||
provider = OpenAITranscriptionProvider(
|
||||
api_key=self.transcription_api_key,
|
||||
api_base=self.transcription_api_base or None,
|
||||
)
|
||||
else:
|
||||
from nanobot.providers.transcription import GroqTranscriptionProvider
|
||||
provider = GroqTranscriptionProvider(api_key=self.transcription_api_key)
|
||||
provider = GroqTranscriptionProvider(
|
||||
api_key=self.transcription_api_key,
|
||||
api_base=self.transcription_api_base or None,
|
||||
)
|
||||
return await provider.transcribe(file_path)
|
||||
except Exception as e:
|
||||
logger.warning("{}: audio transcription failed: {}", self.name, e)
|
||||
@@ -116,7 +123,13 @@ class BaseChannel(ABC):
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
"""Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all."""
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
if isinstance(self.config, dict):
|
||||
if "allow_from" in self.config:
|
||||
allow_list = self.config.get("allow_from")
|
||||
else:
|
||||
allow_list = self.config.get("allowFrom", [])
|
||||
else:
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
if not allow_list:
|
||||
logger.warning("{}: allow_from is empty — all access denied", self.name)
|
||||
return False
|
||||
|
||||
@@ -53,6 +53,7 @@ class DiscordConfig(Base):
|
||||
enabled: bool = False
|
||||
token: str = ""
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
allow_channels: list[str] = Field(default_factory=list) # Allowed channel IDs (empty = all)
|
||||
intents: int = 37377
|
||||
group_policy: Literal["mention", "open"] = "mention"
|
||||
read_receipt_emoji: str = "👀"
|
||||
@@ -450,7 +451,6 @@ class DiscordChannel(BaseChannel):
|
||||
await self._start_typing(message.channel)
|
||||
|
||||
# Add read receipt reaction immediately, working emoji after delay
|
||||
channel_id = self._channel_key(message.channel)
|
||||
try:
|
||||
await message.add_reaction(self.config.read_receipt_emoji)
|
||||
self._pending_reactions[channel_id] = message
|
||||
@@ -534,6 +534,12 @@ class DiscordChannel(BaseChannel):
|
||||
"""Check if inbound Discord message should be processed."""
|
||||
if not self.is_allowed(sender_id):
|
||||
return False
|
||||
# Channel-based filtering: only respond in allowed channels
|
||||
allow_channels = self.config.allow_channels
|
||||
if allow_channels:
|
||||
channel_id = self._channel_key(message.channel)
|
||||
if channel_id not in allow_channels:
|
||||
return False
|
||||
if message.guild is not None and not self._should_respond_in_group(message, content):
|
||||
return False
|
||||
return True
|
||||
|
||||
@@ -118,6 +118,7 @@ class EmailChannel(BaseChannel):
|
||||
config = EmailConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
self.config: EmailConfig = config
|
||||
self._self_addresses = self._collect_self_addresses()
|
||||
self._last_subject_by_chat: dict[str, str] = {}
|
||||
self._last_message_id_by_chat: dict[str, str] = {}
|
||||
self._processed_uids: set[str] = set() # Capped to prevent unbounded growth
|
||||
@@ -379,6 +380,12 @@ class EmailChannel(BaseChannel):
|
||||
sender = parseaddr(parsed.get("From", ""))[1].strip().lower()
|
||||
if not sender:
|
||||
continue
|
||||
if self._is_self_address(sender):
|
||||
logger.info("Email from {} ignored: matches bot-owned address", sender)
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
continue
|
||||
|
||||
# --- Anti-spoofing: verify Authentication-Results ---
|
||||
spf_pass, dkim_pass = self._check_authentication_results(parsed)
|
||||
@@ -446,14 +453,7 @@ class EmailChannel(BaseChannel):
|
||||
}
|
||||
)
|
||||
|
||||
if uid:
|
||||
cycle_uids.add(uid)
|
||||
if dedupe and uid:
|
||||
self._processed_uids.add(uid)
|
||||
# mark_seen is the primary dedup; this set is a safety net
|
||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
||||
# Evict a random half to cap memory; mark_seen is the primary dedup
|
||||
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
|
||||
self._remember_processed_uid(uid, dedupe, cycle_uids)
|
||||
|
||||
if mark_seen:
|
||||
client.store(imap_id, "+FLAGS", "\\Seen")
|
||||
@@ -463,6 +463,50 @@ class EmailChannel(BaseChannel):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _collect_self_addresses(self) -> set[str]:
|
||||
"""Return normalized email addresses owned by this channel instance."""
|
||||
candidates = (
|
||||
self.config.from_address,
|
||||
self.config.smtp_username,
|
||||
self.config.imap_username,
|
||||
)
|
||||
normalized = {
|
||||
addr
|
||||
for candidate in candidates
|
||||
if (addr := self._normalize_address(candidate))
|
||||
}
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_address(value: str) -> str:
|
||||
"""Normalize an address or mailbox-like identifier for comparisons."""
|
||||
raw = (value or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = parseaddr(raw)[1].strip().lower()
|
||||
if parsed:
|
||||
return parsed
|
||||
if "@" in raw:
|
||||
return raw.lower()
|
||||
return ""
|
||||
|
||||
def _is_self_address(self, sender: str) -> bool:
|
||||
"""Return True when an inbound sender belongs to the bot itself."""
|
||||
normalized_sender = self._normalize_address(sender)
|
||||
return bool(normalized_sender) and normalized_sender in self._self_addresses
|
||||
|
||||
def _remember_processed_uid(self, uid: str, dedupe: bool, cycle_uids: set[str]) -> None:
|
||||
"""Track a fetched UID so skipped messages are not reprocessed forever."""
|
||||
if not uid:
|
||||
return
|
||||
cycle_uids.add(uid)
|
||||
if dedupe:
|
||||
self._processed_uids.add(uid)
|
||||
# mark_seen is the primary dedup; this set is a safety net
|
||||
if len(self._processed_uids) > self._MAX_PROCESSED_UIDS:
|
||||
# Evict a random half to cap memory; mark_seen is the primary dedup
|
||||
self._processed_uids = set(list(self._processed_uids)[len(self._processed_uids) // 2:])
|
||||
|
||||
@classmethod
|
||||
def _is_stale_imap_error(cls, exc: Exception) -> bool:
|
||||
message = str(exc).lower()
|
||||
|
||||
+44
-68
@@ -1290,7 +1290,6 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
Supported metadata keys:
|
||||
_stream_end: Finalize the streaming card.
|
||||
_resuming: Mid-turn pause – flush but keep the buffer alive.
|
||||
_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.
|
||||
@@ -1309,50 +1308,44 @@ class FeishuChannel(BaseChannel):
|
||||
if self.config.done_emoji and message_id:
|
||||
await self._add_reaction(message_id, self.config.done_emoji)
|
||||
|
||||
resuming = meta.get("_resuming", False)
|
||||
if resuming:
|
||||
# Mid-turn pause (e.g. tool call between streaming segments).
|
||||
# Flush current text to card but keep the buffer alive so the
|
||||
# next segment appends to the same card.
|
||||
buf = self._stream_bufs.get(chat_id)
|
||||
if buf and buf.card_id and buf.text:
|
||||
buf.sequence += 1
|
||||
await loop.run_in_executor(
|
||||
None, self._stream_update_text_sync, buf.card_id, buf.text, buf.sequence,
|
||||
)
|
||||
return
|
||||
|
||||
buf = self._stream_bufs.pop(chat_id, None)
|
||||
if not buf or not buf.text:
|
||||
return
|
||||
# Try to finalize via streaming card; if that fails (e.g.
|
||||
# streaming mode was closed by Feishu due to timeout), fall
|
||||
# back to sending a regular interactive card.
|
||||
if buf.card_id:
|
||||
buf.sequence += 1
|
||||
await loop.run_in_executor(
|
||||
ok = await loop.run_in_executor(
|
||||
None,
|
||||
self._stream_update_text_sync,
|
||||
buf.card_id,
|
||||
buf.text,
|
||||
buf.sequence,
|
||||
)
|
||||
# Required so the chat list preview exits the streaming placeholder (Feishu streaming card docs).
|
||||
buf.sequence += 1
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._close_streaming_mode_sync,
|
||||
buf.card_id,
|
||||
buf.sequence,
|
||||
)
|
||||
else:
|
||||
for chunk in self._split_elements_by_table_limit(
|
||||
self._build_card_elements(buf.text)
|
||||
):
|
||||
card = json.dumps(
|
||||
{"config": {"wide_screen_mode": True}, "elements": chunk},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
if ok:
|
||||
buf.sequence += 1
|
||||
await loop.run_in_executor(
|
||||
None, self._send_message_sync, rid_type, chat_id, "interactive", card
|
||||
None,
|
||||
self._close_streaming_mode_sync,
|
||||
buf.card_id,
|
||||
buf.sequence,
|
||||
)
|
||||
return
|
||||
logger.warning(
|
||||
"Streaming card {} final update failed, falling back to regular card",
|
||||
buf.card_id,
|
||||
)
|
||||
for chunk in self._split_elements_by_table_limit(
|
||||
self._build_card_elements(buf.text)
|
||||
):
|
||||
card = json.dumps(
|
||||
{"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
|
||||
)
|
||||
return
|
||||
|
||||
# --- accumulate delta ---
|
||||
@@ -1404,14 +1397,21 @@ class FeishuChannel(BaseChannel):
|
||||
if buf and buf.card_id:
|
||||
# Delegate to send_delta so tool hints get the same
|
||||
# throttling (and card creation) as regular text deltas.
|
||||
lines = self.__class__._format_tool_hint_lines(hint).split("\n")
|
||||
delta = "\n\n" + "\n".join(
|
||||
f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
|
||||
) + "\n\n"
|
||||
await self.send_delta(msg.chat_id, delta)
|
||||
await self.send_delta(
|
||||
msg.chat_id,
|
||||
"\n\n" + self._format_tool_hint_delta(hint) + "\n\n",
|
||||
)
|
||||
return
|
||||
await self._send_tool_hint_card(
|
||||
receive_id_type, msg.chat_id, hint
|
||||
# No active streaming card — send as a regular
|
||||
# interactive card with the same 🔧 prefix style.
|
||||
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
|
||||
)
|
||||
return
|
||||
|
||||
@@ -1708,33 +1708,9 @@ class FeishuChannel(BaseChannel):
|
||||
|
||||
return "\n".join(part for part in parts if part)
|
||||
|
||||
async def _send_tool_hint_card(
|
||||
self, receive_id_type: str, receive_id: str, tool_hint: str
|
||||
) -> None:
|
||||
"""Send tool hint as an interactive card with formatted code block.
|
||||
|
||||
Args:
|
||||
receive_id_type: "chat_id" or "open_id"
|
||||
receive_id: The target chat or user ID
|
||||
tool_hint: Formatted tool hint string (e.g., 'web_search("q"), read_file("path")')
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Put each top-level tool call on its own line without altering commas inside arguments.
|
||||
formatted_code = self.__class__._format_tool_hint_lines(tool_hint)
|
||||
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": [
|
||||
{"tag": "markdown", "content": f"**Tool Calls**\n\n```text\n{formatted_code}\n```"}
|
||||
],
|
||||
}
|
||||
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
self._send_message_sync,
|
||||
receive_id_type,
|
||||
receive_id,
|
||||
"interactive",
|
||||
json.dumps(card, ensure_ascii=False),
|
||||
def _format_tool_hint_delta(self, tool_hint: str) -> str:
|
||||
"""Format a tool hint string with the 🔧 prefix for each line."""
|
||||
lines = self.__class__._format_tool_hint_lines(tool_hint).split("\n")
|
||||
return "\n".join(
|
||||
f"{self.config.tool_hint_prefix} {ln}" for ln in lines if ln.strip()
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ class ChannelManager:
|
||||
|
||||
transcription_provider = self.config.channels.transcription_provider
|
||||
transcription_key = self._resolve_transcription_key(transcription_provider)
|
||||
transcription_base = self._resolve_transcription_base(transcription_provider)
|
||||
|
||||
for name, cls in discover_all().items():
|
||||
section = getattr(self.config.channels, name, None)
|
||||
@@ -57,6 +58,7 @@ class ChannelManager:
|
||||
channel = cls(section, self.bus)
|
||||
channel.transcription_provider = transcription_provider
|
||||
channel.transcription_api_key = transcription_key
|
||||
channel.transcription_api_base = transcription_base
|
||||
self.channels[name] = channel
|
||||
logger.info("{} channel enabled", cls.display_name)
|
||||
except Exception as e:
|
||||
@@ -73,9 +75,26 @@ class ChannelManager:
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def _resolve_transcription_base(self, provider: str) -> str:
|
||||
"""Pick the API base URL for the configured transcription provider."""
|
||||
try:
|
||||
if provider == "openai":
|
||||
return self.config.providers.openai.api_base or ""
|
||||
return self.config.providers.groq.api_base or ""
|
||||
except AttributeError:
|
||||
return ""
|
||||
|
||||
def _validate_allow_from(self) -> None:
|
||||
for name, ch in self.channels.items():
|
||||
if getattr(ch.config, "allow_from", None) == []:
|
||||
cfg = ch.config
|
||||
if isinstance(cfg, dict):
|
||||
if "allow_from" in cfg:
|
||||
allow = cfg.get("allow_from")
|
||||
else:
|
||||
allow = cfg.get("allowFrom")
|
||||
else:
|
||||
allow = getattr(cfg, "allow_from", None)
|
||||
if allow == []:
|
||||
raise SystemExit(
|
||||
f'Error: "{name}" has empty allowFrom (denies all). '
|
||||
f'Set ["*"] to allow everyone, or add specific user IDs.'
|
||||
@@ -170,6 +189,9 @@ class ChannelManager:
|
||||
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
|
||||
continue
|
||||
|
||||
if msg.metadata.get("_retry_wait"):
|
||||
continue
|
||||
|
||||
# Coalesce consecutive _stream_delta messages for the same (channel, chat_id)
|
||||
# to reduce API calls and improve streaming latency
|
||||
if msg.metadata.get("_stream_delta") and not msg.metadata.get("_stream_end"):
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Microsoft Teams channel MVP using a tiny built-in HTTP webhook server.
|
||||
|
||||
Scope:
|
||||
- DM-focused MVP
|
||||
- text inbound/outbound
|
||||
- conversation reference persistence
|
||||
- sender allowlist support
|
||||
- optional inbound Bot Framework bearer-token validation
|
||||
- no attachments/cards/polls yet
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import html
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_workspace_path
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
MSTEAMS_AVAILABLE = (
|
||||
importlib.util.find_spec("jwt") is not None
|
||||
and importlib.util.find_spec("cryptography") is not None
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import jwt
|
||||
|
||||
if MSTEAMS_AVAILABLE:
|
||||
import jwt
|
||||
|
||||
|
||||
class MSTeamsConfig(Base):
|
||||
"""Microsoft Teams channel configuration."""
|
||||
|
||||
enabled: bool = False
|
||||
app_id: str = ""
|
||||
app_password: str = ""
|
||||
tenant_id: str = ""
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 3978
|
||||
path: str = "/api/messages"
|
||||
allow_from: list[str] = Field(default_factory=list)
|
||||
reply_in_thread: bool = True
|
||||
mention_only_response: str = "Hi — what can I help with?"
|
||||
validate_inbound_auth: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationRef:
|
||||
"""Minimal stored conversation reference for replies."""
|
||||
|
||||
service_url: str
|
||||
conversation_id: str
|
||||
bot_id: str | None = None
|
||||
activity_id: str | None = None
|
||||
conversation_type: str | None = None
|
||||
tenant_id: str | None = None
|
||||
|
||||
|
||||
class MSTeamsChannel(BaseChannel):
|
||||
"""Microsoft Teams channel (DM-first MVP)."""
|
||||
|
||||
name = "msteams"
|
||||
display_name = "Microsoft Teams"
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return MSTeamsConfig().model_dump(by_alias=True)
|
||||
|
||||
def __init__(self, config: Any, bus: MessageBus):
|
||||
if isinstance(config, dict):
|
||||
config = MSTeamsConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
self.config: MSTeamsConfig = config
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
self._server: ThreadingHTTPServer | None = None
|
||||
self._server_thread: threading.Thread | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._token: str | None = None
|
||||
self._token_expires_at: float = 0.0
|
||||
self._botframework_openid_config_url = (
|
||||
"https://login.botframework.com/v1/.well-known/openidconfiguration"
|
||||
)
|
||||
self._botframework_openid_config: dict[str, Any] | None = None
|
||||
self._botframework_openid_config_expires_at: float = 0.0
|
||||
self._botframework_jwks: dict[str, Any] | None = None
|
||||
self._botframework_jwks_expires_at: float = 0.0
|
||||
self._refs_path = get_workspace_path() / "state" / "msteams_conversations.json"
|
||||
self._refs_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conversation_refs: dict[str, ConversationRef] = self._load_refs()
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Teams webhook listener."""
|
||||
if not MSTEAMS_AVAILABLE:
|
||||
logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||
return
|
||||
|
||||
if not self.config.app_id or not self.config.app_password:
|
||||
logger.error("MSTeams app_id/app_password not configured")
|
||||
return
|
||||
|
||||
if not self.config.validate_inbound_auth:
|
||||
logger.warning(
|
||||
"MSTeams inbound auth validation was explicitly DISABLED in config. "
|
||||
"Anyone who knows the webhook URL can send messages as any user. "
|
||||
"Only disable this for local development or controlled testing."
|
||||
)
|
||||
|
||||
self._loop = asyncio.get_running_loop()
|
||||
self._http = httpx.AsyncClient(timeout=30.0)
|
||||
self._running = True
|
||||
|
||||
channel = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self) -> None:
|
||||
if self.path != channel.config.path:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("MSTeams invalid request body: {}", e)
|
||||
self.send_response(400)
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
auth_header = self.headers.get("Authorization", "")
|
||||
if channel.config.validate_inbound_auth:
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(
|
||||
channel._validate_inbound_auth(auth_header, payload),
|
||||
channel._loop,
|
||||
)
|
||||
fut.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.warning("MSTeams inbound auth validation failed: {}", e)
|
||||
self.send_response(401)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b'{"error":"unauthorized"}')
|
||||
return
|
||||
try:
|
||||
fut = asyncio.run_coroutine_threadsafe(
|
||||
channel._handle_activity(payload),
|
||||
channel._loop,
|
||||
)
|
||||
fut.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.warning("MSTeams activity handling failed: {}", e)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"{}")
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
self._server = ThreadingHTTPServer((self.config.host, self.config.port), Handler)
|
||||
self._server_thread = threading.Thread(
|
||||
target=self._server.serve_forever,
|
||||
name="nanobot-msteams",
|
||||
daemon=True,
|
||||
)
|
||||
self._server_thread.start()
|
||||
|
||||
logger.info(
|
||||
"MSTeams webhook listening on http://{}:{}{}",
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
self.config.path,
|
||||
)
|
||||
|
||||
while self._running:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the channel."""
|
||||
self._running = False
|
||||
if self._server:
|
||||
self._server.shutdown()
|
||||
self._server.server_close()
|
||||
self._server = None
|
||||
if self._server_thread and self._server_thread.is_alive():
|
||||
self._server_thread.join(timeout=2)
|
||||
self._server_thread = None
|
||||
if self._http:
|
||||
await self._http.aclose()
|
||||
self._http = None
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
"""Send a plain text reply into an existing Teams conversation."""
|
||||
if not self._http:
|
||||
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||
|
||||
ref = self._conversation_refs.get(str(msg.chat_id))
|
||||
if not ref:
|
||||
raise RuntimeError(f"MSTeams conversation ref not found for chat_id={msg.chat_id}")
|
||||
|
||||
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",
|
||||
}
|
||||
payload = {
|
||||
"type": "message",
|
||||
"text": msg.content or " ",
|
||||
}
|
||||
if use_thread_reply:
|
||||
payload["replyToId"] = ref.activity_id
|
||||
|
||||
try:
|
||||
resp = await self._http.post(url, headers=headers, json=payload)
|
||||
resp.raise_for_status()
|
||||
logger.info("MSTeams message sent to {}", ref.conversation_id)
|
||||
except Exception as e:
|
||||
logger.error("MSTeams send failed: {}", e)
|
||||
raise
|
||||
|
||||
async def _handle_activity(self, activity: dict[str, Any]) -> None:
|
||||
"""Handle inbound Teams/Bot Framework activity."""
|
||||
if activity.get("type") != "message":
|
||||
return
|
||||
|
||||
conversation = activity.get("conversation") or {}
|
||||
from_user = activity.get("from") or {}
|
||||
recipient = activity.get("recipient") or {}
|
||||
channel_data = activity.get("channelData") or {}
|
||||
|
||||
sender_id = str(from_user.get("aadObjectId") or from_user.get("id") or "").strip()
|
||||
conversation_id = str(conversation.get("id") or "").strip()
|
||||
service_url = str(activity.get("serviceUrl") or "").strip()
|
||||
activity_id = str(activity.get("id") or "").strip()
|
||||
conversation_type = str(conversation.get("conversationType") or "").strip()
|
||||
|
||||
if not sender_id or not conversation_id or not service_url:
|
||||
return
|
||||
|
||||
if recipient.get("id") and from_user.get("id") == recipient.get("id"):
|
||||
return
|
||||
|
||||
# DM-only MVP: ignore group/channel traffic for now
|
||||
if conversation_type and conversation_type not in ("personal", ""):
|
||||
logger.debug("MSTeams ignoring non-DM conversation {}", conversation_type)
|
||||
return
|
||||
|
||||
text = self._sanitize_inbound_text(activity)
|
||||
if not text:
|
||||
text = self.config.mention_only_response.strip()
|
||||
if not text:
|
||||
logger.debug("MSTeams ignoring empty message after Teams text sanitization")
|
||||
return
|
||||
|
||||
if not self.is_allowed(sender_id):
|
||||
logger.warning(
|
||||
"Access denied for sender {} on channel {}. "
|
||||
"Add them to allowFrom list in config to grant access.",
|
||||
sender_id, self.name,
|
||||
)
|
||||
return
|
||||
|
||||
self._conversation_refs[conversation_id] = ConversationRef(
|
||||
service_url=service_url,
|
||||
conversation_id=conversation_id,
|
||||
bot_id=str(recipient.get("id") or "") or None,
|
||||
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,
|
||||
)
|
||||
self._save_refs()
|
||||
|
||||
await self._handle_message(
|
||||
sender_id=sender_id,
|
||||
chat_id=conversation_id,
|
||||
content=text,
|
||||
metadata={
|
||||
"msteams": {
|
||||
"activity_id": activity_id,
|
||||
"conversation_id": conversation_id,
|
||||
"conversation_type": conversation_type or "personal",
|
||||
"from_name": from_user.get("name"),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def _sanitize_inbound_text(self, activity: dict[str, Any]) -> str:
|
||||
"""Extract the user-authored text from a Teams activity."""
|
||||
text = str(activity.get("text") or "")
|
||||
text = self._strip_possible_bot_mention(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("\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]:
|
||||
preview_lines.pop(0)
|
||||
first_line = preview_lines[0] if preview_lines else ""
|
||||
looks_like_quote_wrapper = first_line.lower().startswith("replying to ") or first_line.startswith("Reply wrapper")
|
||||
|
||||
if reply_to_id or channel_data.get("messageType") == "reply" or looks_like_quote_wrapper:
|
||||
text = self._normalize_teams_reply_quote(text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
def _strip_possible_bot_mention(self, text: str) -> str:
|
||||
"""Remove simple Teams mention markup from message text."""
|
||||
cleaned = re.sub(r"<at\b[^>]*>.*?</at>", " ", text, flags=re.IGNORECASE | re.DOTALL)
|
||||
cleaned = re.sub(r"[^\S\r\n]+", " ", cleaned)
|
||||
cleaned = re.sub(r"(?:\r?\n){3,}", "\n\n", cleaned)
|
||||
return cleaned.strip()
|
||||
|
||||
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()
|
||||
if not cleaned:
|
||||
return ""
|
||||
|
||||
normalized_newlines = cleaned.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [line.strip() for line in normalized_newlines.split("\n")]
|
||||
while lines and not lines[0]:
|
||||
lines.pop(0)
|
||||
|
||||
# Observed native Teams reply wrapper:
|
||||
# Replying to Bob Smith
|
||||
# actual reply text
|
||||
if len(lines) >= 2 and lines[0].lower().startswith("replying to "):
|
||||
quoted = lines[0][len("replying to ") :].strip(" :")
|
||||
reply = "\n".join(lines[1:]).strip()
|
||||
return self._format_reply_with_quote(quoted, reply)
|
||||
|
||||
# Observed reply wrapper where the quoted content is surfaced after a
|
||||
# synthetic "Reply wrapper" header, sometimes with a blank line separating quote
|
||||
# and reply, and sometimes as a compact line-based fallback shape.
|
||||
if lines and lines[0].strip().startswith("Reply wrapper"):
|
||||
body = normalized_newlines.split("\n", 1)[1] if "\n" in normalized_newlines else ""
|
||||
body = body.lstrip()
|
||||
parts = re.split(r"\n\s*\n", body, maxsplit=1)
|
||||
if len(parts) == 2:
|
||||
quoted = re.sub(r"\s+", " ", parts[0]).strip()
|
||||
reply = re.sub(r"\s+", " ", parts[1]).strip()
|
||||
if quoted or reply:
|
||||
return self._format_reply_with_quote(quoted, reply)
|
||||
|
||||
body_lines = [line.strip() for line in body.split("\n") if line.strip()]
|
||||
if body_lines:
|
||||
quoted = " ".join(body_lines[:-1]).strip()
|
||||
reply = body_lines[-1].strip()
|
||||
if quoted and reply:
|
||||
return self._format_reply_with_quote(quoted, reply)
|
||||
|
||||
# Observed compact fallback where the relay flattens quote and reply into
|
||||
# a single line after the synthetic Reply wrapper prefix.
|
||||
compact = re.sub(r"\s+", " ", normalized_newlines).strip()
|
||||
if compact.startswith("Reply wrapper "):
|
||||
compact = compact[len("Reply wrapper ") :].strip()
|
||||
for boundary in (". ", "! ", "? ", "… "):
|
||||
idx = compact.rfind(boundary)
|
||||
if idx == -1:
|
||||
continue
|
||||
quoted = compact[: idx + 1].strip()
|
||||
reply = compact[idx + len(boundary) :].strip()
|
||||
if quoted and reply and len(reply) <= 160:
|
||||
return self._format_reply_with_quote(quoted, reply)
|
||||
|
||||
return cleaned
|
||||
|
||||
def _format_reply_with_quote(self, quoted: str, reply: str) -> str:
|
||||
"""Format a reply-with-context message for the model without Teams wrapper noise."""
|
||||
quoted = quoted.strip()
|
||||
reply = reply.strip()
|
||||
if quoted and reply:
|
||||
return f"User is replying to: {quoted}\nUser reply: {reply}"
|
||||
if reply:
|
||||
return reply
|
||||
return quoted
|
||||
|
||||
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
|
||||
"""Validate inbound Bot Framework bearer token."""
|
||||
if not MSTEAMS_AVAILABLE:
|
||||
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||
|
||||
if not auth_header.lower().startswith("bearer "):
|
||||
raise ValueError("missing bearer token")
|
||||
|
||||
token = auth_header.split(" ", 1)[1].strip()
|
||||
if not token:
|
||||
raise ValueError("empty bearer token")
|
||||
|
||||
header = jwt.get_unverified_header(token)
|
||||
kid = str(header.get("kid") or "").strip()
|
||||
if not kid:
|
||||
raise ValueError("missing token kid")
|
||||
|
||||
jwks = await self._get_botframework_jwks()
|
||||
keys = jwks.get("keys") or []
|
||||
jwk = next((key for key in keys if key.get("kid") == kid), None)
|
||||
if not jwk:
|
||||
raise ValueError(f"signing key not found for kid={kid}")
|
||||
|
||||
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(jwk))
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
key=public_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.config.app_id,
|
||||
issuer="https://api.botframework.com",
|
||||
options={
|
||||
"require": ["exp", "nbf", "iss", "aud"],
|
||||
},
|
||||
)
|
||||
|
||||
claim_service_url = str(
|
||||
claims.get("serviceurl") or claims.get("serviceUrl") or "",
|
||||
).strip()
|
||||
activity_service_url = str(activity.get("serviceUrl") or "").strip()
|
||||
if claim_service_url and activity_service_url and claim_service_url != activity_service_url:
|
||||
raise ValueError("serviceUrl claim mismatch")
|
||||
|
||||
async def _get_botframework_openid_config(self) -> dict[str, Any]:
|
||||
"""Fetch and cache Bot Framework OpenID configuration."""
|
||||
|
||||
now = time.time()
|
||||
if self._botframework_openid_config and now < self._botframework_openid_config_expires_at:
|
||||
return self._botframework_openid_config
|
||||
|
||||
if not self._http:
|
||||
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||
|
||||
resp = await self._http.get(self._botframework_openid_config_url)
|
||||
resp.raise_for_status()
|
||||
self._botframework_openid_config = resp.json()
|
||||
self._botframework_openid_config_expires_at = now + 3600
|
||||
return self._botframework_openid_config
|
||||
|
||||
async def _get_botframework_jwks(self) -> dict[str, Any]:
|
||||
"""Fetch and cache Bot Framework JWKS."""
|
||||
|
||||
now = time.time()
|
||||
if self._botframework_jwks and now < self._botframework_jwks_expires_at:
|
||||
return self._botframework_jwks
|
||||
|
||||
if not self._http:
|
||||
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||
|
||||
openid_config = await self._get_botframework_openid_config()
|
||||
jwks_uri = str(openid_config.get("jwks_uri") or "").strip()
|
||||
if not jwks_uri:
|
||||
raise RuntimeError("Bot Framework OpenID config missing jwks_uri")
|
||||
|
||||
resp = await self._http.get(jwks_uri)
|
||||
resp.raise_for_status()
|
||||
self._botframework_jwks = resp.json()
|
||||
self._botframework_jwks_expires_at = now + 3600
|
||||
return self._botframework_jwks
|
||||
|
||||
def _load_refs(self) -> dict[str, ConversationRef]:
|
||||
"""Load stored conversation references."""
|
||||
if not self._refs_path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(self._refs_path.read_text(encoding="utf-8"))
|
||||
out: dict[str, ConversationRef] = {}
|
||||
for key, value in data.items():
|
||||
out[key] = ConversationRef(**value)
|
||||
return out
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load MSTeams conversation refs: {}", e)
|
||||
return {}
|
||||
|
||||
def _save_refs(self) -> None:
|
||||
"""Persist conversation references."""
|
||||
try:
|
||||
data = {
|
||||
key: {
|
||||
"service_url": ref.service_url,
|
||||
"conversation_id": ref.conversation_id,
|
||||
"bot_id": ref.bot_id,
|
||||
"activity_id": ref.activity_id,
|
||||
"conversation_type": ref.conversation_type,
|
||||
"tenant_id": ref.tenant_id,
|
||||
}
|
||||
for key, ref in self._conversation_refs.items()
|
||||
}
|
||||
self._refs_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save MSTeams conversation refs: {}", e)
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""Fetch an access token for Bot Framework / Azure Bot auth."""
|
||||
|
||||
now = time.time()
|
||||
if self._token and now < self._token_expires_at - 60:
|
||||
return self._token
|
||||
|
||||
if not self._http:
|
||||
raise RuntimeError("MSTeams HTTP client not initialized")
|
||||
|
||||
tenant = (self.config.tenant_id or "").strip() or "botframework.com"
|
||||
token_url = f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
|
||||
data = {
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.config.app_id,
|
||||
"client_secret": self.config.app_password,
|
||||
"scope": "https://api.botframework.com/.default",
|
||||
}
|
||||
resp = await self._http.post(token_url, data=data)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
self._token = payload["access_token"]
|
||||
self._token_expires_at = now + int(payload.get("expires_in", 3600))
|
||||
return self._token
|
||||
+126
-6
@@ -5,6 +5,7 @@ import re
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
from slack_sdk.socket_mode.request import SocketModeRequest
|
||||
from slack_sdk.socket_mode.response import SocketModeResponse
|
||||
from slack_sdk.socket_mode.websockets import SocketModeClient
|
||||
@@ -13,8 +14,6 @@ from slackify_markdown import slackify_markdown
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Base
|
||||
|
||||
@@ -50,6 +49,9 @@ class SlackChannel(BaseChannel):
|
||||
|
||||
name = "slack"
|
||||
display_name = "Slack"
|
||||
_SLACK_ID_RE = re.compile(r"^[CDGUW][A-Z0-9]{2,}$")
|
||||
_SLACK_CHANNEL_REF_RE = re.compile(r"^<#([A-Z0-9]+)(?:\|[^>]+)?>$")
|
||||
_SLACK_USER_REF_RE = re.compile(r"^<@([A-Z0-9]+)(?:\|[^>]+)?>$")
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
@@ -63,6 +65,7 @@ class SlackChannel(BaseChannel):
|
||||
self._web_client: AsyncWebClient | None = None
|
||||
self._socket_client: SocketModeClient | None = None
|
||||
self._bot_user_id: str | None = None
|
||||
self._target_cache: dict[str, str] = {}
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the Slack Socket Mode client."""
|
||||
@@ -113,17 +116,23 @@ class SlackChannel(BaseChannel):
|
||||
logger.warning("Slack client not running")
|
||||
return
|
||||
try:
|
||||
target_chat_id = await self._resolve_target_chat_id(msg.chat_id)
|
||||
slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
|
||||
thread_ts = slack_meta.get("thread_ts")
|
||||
channel_type = slack_meta.get("channel_type")
|
||||
origin_chat_id = str((slack_meta.get("event", {}) or {}).get("channel") or msg.chat_id)
|
||||
# Slack DMs don't use threads; channel/group replies may keep thread_ts.
|
||||
thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None
|
||||
thread_ts_param = (
|
||||
thread_ts
|
||||
if thread_ts and channel_type != "im" and target_chat_id == origin_chat_id
|
||||
else None
|
||||
)
|
||||
|
||||
# Slack rejects empty text payloads. Keep media-only messages media-only,
|
||||
# but send a single blank message when the bot has no text or files to send.
|
||||
if msg.content or not (msg.media or []):
|
||||
await self._web_client.chat_postMessage(
|
||||
channel=msg.chat_id,
|
||||
channel=target_chat_id,
|
||||
text=self._to_mrkdwn(msg.content) if msg.content else " ",
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
@@ -131,7 +140,7 @@ class SlackChannel(BaseChannel):
|
||||
for media_path in msg.media or []:
|
||||
try:
|
||||
await self._web_client.files_upload_v2(
|
||||
channel=msg.chat_id,
|
||||
channel=target_chat_id,
|
||||
file=media_path,
|
||||
thread_ts=thread_ts_param,
|
||||
)
|
||||
@@ -141,12 +150,123 @@ class SlackChannel(BaseChannel):
|
||||
# Update reaction emoji when the final (non-progress) response is sent
|
||||
if not (msg.metadata or {}).get("_progress"):
|
||||
event = slack_meta.get("event", {})
|
||||
await self._update_react_emoji(msg.chat_id, event.get("ts"))
|
||||
await self._update_react_emoji(origin_chat_id, event.get("ts"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error sending Slack message: {}", e)
|
||||
raise
|
||||
|
||||
async def _resolve_target_chat_id(self, target: str) -> str:
|
||||
"""Resolve human-friendly Slack targets to concrete IDs when needed."""
|
||||
if not self._web_client:
|
||||
return target
|
||||
|
||||
target = target.strip()
|
||||
if not target:
|
||||
return target
|
||||
|
||||
if match := self._SLACK_CHANNEL_REF_RE.fullmatch(target):
|
||||
return match.group(1)
|
||||
if match := self._SLACK_USER_REF_RE.fullmatch(target):
|
||||
return await self._open_dm_for_user(match.group(1))
|
||||
if self._SLACK_ID_RE.fullmatch(target):
|
||||
if target.startswith(("U", "W")):
|
||||
return await self._open_dm_for_user(target)
|
||||
return target
|
||||
|
||||
if target.startswith("#"):
|
||||
return await self._resolve_channel_name(target[1:])
|
||||
if target.startswith("@"):
|
||||
return await self._resolve_user_handle(target[1:])
|
||||
|
||||
try:
|
||||
return await self._resolve_channel_name(target)
|
||||
except ValueError:
|
||||
return await self._resolve_user_handle(target)
|
||||
|
||||
async def _resolve_channel_name(self, name: str) -> str:
|
||||
normalized = self._normalize_target_name(name)
|
||||
if not normalized:
|
||||
raise ValueError("Slack target channel name is empty")
|
||||
|
||||
cache_key = f"channel:{normalized}"
|
||||
if cache_key in self._target_cache:
|
||||
return self._target_cache[cache_key]
|
||||
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
response = await self._web_client.conversations_list(
|
||||
types="public_channel,private_channel",
|
||||
exclude_archived=True,
|
||||
limit=200,
|
||||
cursor=cursor,
|
||||
)
|
||||
for channel in response.get("channels", []):
|
||||
if self._normalize_target_name(str(channel.get("name") or "")) == normalized:
|
||||
channel_id = str(channel.get("id") or "")
|
||||
if channel_id:
|
||||
self._target_cache[cache_key] = channel_id
|
||||
return channel_id
|
||||
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
raise ValueError(
|
||||
f"Slack channel '{name}' was not found. Use a joined channel name like "
|
||||
f"'#general' or a concrete channel ID."
|
||||
)
|
||||
|
||||
async def _resolve_user_handle(self, handle: str) -> str:
|
||||
normalized = self._normalize_target_name(handle)
|
||||
if not normalized:
|
||||
raise ValueError("Slack target user handle is empty")
|
||||
|
||||
cache_key = f"user:{normalized}"
|
||||
if cache_key in self._target_cache:
|
||||
return self._target_cache[cache_key]
|
||||
|
||||
cursor: str | None = None
|
||||
while True:
|
||||
response = await self._web_client.users_list(limit=200, cursor=cursor)
|
||||
for member in response.get("members", []):
|
||||
if self._member_matches_handle(member, normalized):
|
||||
user_id = str(member.get("id") or "")
|
||||
if not user_id:
|
||||
continue
|
||||
dm_id = await self._open_dm_for_user(user_id)
|
||||
self._target_cache[cache_key] = dm_id
|
||||
return dm_id
|
||||
cursor = ((response.get("response_metadata") or {}).get("next_cursor") or "").strip()
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
raise ValueError(
|
||||
f"Slack user '{handle}' was not found. Use '@name' or a concrete DM/channel ID."
|
||||
)
|
||||
|
||||
async def _open_dm_for_user(self, user_id: str) -> str:
|
||||
response = await self._web_client.conversations_open(users=user_id)
|
||||
channel_id = str(((response.get("channel") or {}).get("id")) or "")
|
||||
if not channel_id:
|
||||
raise ValueError(f"Slack DM target for user '{user_id}' could not be opened.")
|
||||
return channel_id
|
||||
|
||||
@staticmethod
|
||||
def _normalize_target_name(value: str) -> str:
|
||||
return value.strip().lstrip("#@").lower()
|
||||
|
||||
@classmethod
|
||||
def _member_matches_handle(cls, member: dict[str, Any], normalized: str) -> bool:
|
||||
profile = member.get("profile") or {}
|
||||
candidates = {
|
||||
str(member.get("name") or ""),
|
||||
str(profile.get("display_name") or ""),
|
||||
str(profile.get("display_name_normalized") or ""),
|
||||
str(profile.get("real_name") or ""),
|
||||
str(profile.get("real_name_normalized") or ""),
|
||||
}
|
||||
return normalized in {cls._normalize_target_name(candidate) for candidate in candidates if candidate}
|
||||
|
||||
async def _on_socket_request(
|
||||
self,
|
||||
client: SocketModeClient,
|
||||
|
||||
+144
-22
@@ -7,6 +7,7 @@ import email.utils
|
||||
import hmac
|
||||
import http
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import ssl
|
||||
import time
|
||||
@@ -19,7 +20,8 @@ from pydantic import Field, field_validator, model_validator
|
||||
from websockets.asyncio.server import ServerConnection, serve
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest, Response
|
||||
from websockets.http11 import Request as WsRequest
|
||||
from websockets.http11 import Response
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
@@ -156,6 +158,37 @@ def _parse_inbound_payload(raw: str) -> str | None:
|
||||
return text
|
||||
|
||||
|
||||
# Accept UUIDs and short scoped keys like "unified:default". Keeps the capability
|
||||
# namespace small enough to rule out path traversal / quote injection tricks.
|
||||
_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
|
||||
|
||||
|
||||
def _is_valid_chat_id(value: Any) -> bool:
|
||||
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
|
||||
|
||||
|
||||
def _parse_envelope(raw: str) -> dict[str, Any] | None:
|
||||
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
|
||||
|
||||
A frame qualifies when it parses as a JSON object with a string ``type`` field.
|
||||
Legacy frames (plain text, or ``{"content": ...}`` without ``type``) return None;
|
||||
callers should fall back to :func:`_parse_inbound_payload` for those.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if not text.startswith("{"):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
t = data.get("type")
|
||||
if not isinstance(t, str):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _issue_route_secret_matches(headers: Any, configured_secret: str) -> bool:
|
||||
"""Return True if the token-issue HTTP request carries credentials matching ``token_issue_secret``."""
|
||||
if not configured_secret:
|
||||
@@ -181,11 +214,47 @@ class WebSocketChannel(BaseChannel):
|
||||
config = WebSocketConfig.model_validate(config)
|
||||
super().__init__(config, bus)
|
||||
self.config: WebSocketConfig = config
|
||||
self._connections: dict[str, Any] = {}
|
||||
# chat_id -> connections subscribed to it (fan-out target).
|
||||
self._subs: dict[str, set[Any]] = {}
|
||||
# connection -> chat_ids it is subscribed to (O(1) cleanup on disconnect).
|
||||
self._conn_chats: dict[Any, set[str]] = {}
|
||||
# connection -> default chat_id for legacy frames that omit routing.
|
||||
self._conn_default: dict[Any, str] = {}
|
||||
self._issued_tokens: dict[str, float] = {}
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._server_task: asyncio.Task[None] | None = None
|
||||
|
||||
# -- Subscription bookkeeping -------------------------------------------
|
||||
|
||||
def _attach(self, connection: Any, chat_id: str) -> None:
|
||||
"""Idempotently subscribe *connection* to *chat_id*."""
|
||||
self._subs.setdefault(chat_id, set()).add(connection)
|
||||
self._conn_chats.setdefault(connection, set()).add(chat_id)
|
||||
|
||||
def _cleanup_connection(self, connection: Any) -> None:
|
||||
"""Remove *connection* from every subscription set; safe to call multiple times."""
|
||||
chat_ids = self._conn_chats.pop(connection, set())
|
||||
for cid in chat_ids:
|
||||
subs = self._subs.get(cid)
|
||||
if subs is None:
|
||||
continue
|
||||
subs.discard(connection)
|
||||
if not subs:
|
||||
self._subs.pop(cid, None)
|
||||
self._conn_default.pop(connection, None)
|
||||
|
||||
async def _send_event(self, connection: Any, event: str, **fields: Any) -> None:
|
||||
"""Send a control event (attached, error, ...) to a single connection."""
|
||||
payload: dict[str, Any] = {"event": event}
|
||||
payload.update(fields)
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
self._cleanup_connection(connection)
|
||||
except Exception as e:
|
||||
logger.warning("websocket: failed to send {} event: {}", event, e)
|
||||
|
||||
@classmethod
|
||||
def default_config(cls) -> dict[str, Any]:
|
||||
return WebSocketConfig().model_dump(by_alias=True)
|
||||
@@ -353,21 +422,22 @@ class WebSocketChannel(BaseChannel):
|
||||
logger.warning("websocket: client_id too long ({} chars), truncating", len(client_id))
|
||||
client_id = client_id[:128]
|
||||
|
||||
chat_id = str(uuid.uuid4())
|
||||
default_chat_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
await connection.send(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "ready",
|
||||
"chat_id": chat_id,
|
||||
"chat_id": default_chat_id,
|
||||
"client_id": client_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
# Register only after ready is successfully sent to avoid out-of-order sends
|
||||
self._connections[chat_id] = connection
|
||||
self._conn_default[connection] = default_chat_id
|
||||
self._attach(connection, default_chat_id)
|
||||
|
||||
async for raw in connection:
|
||||
if isinstance(raw, bytes):
|
||||
@@ -376,19 +446,66 @@ class WebSocketChannel(BaseChannel):
|
||||
except UnicodeDecodeError:
|
||||
logger.warning("websocket: ignoring non-utf8 binary frame")
|
||||
continue
|
||||
|
||||
envelope = _parse_envelope(raw)
|
||||
if envelope is not None:
|
||||
await self._dispatch_envelope(connection, client_id, envelope)
|
||||
continue
|
||||
|
||||
content = _parse_inbound_payload(raw)
|
||||
if content is None:
|
||||
continue
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=chat_id,
|
||||
chat_id=default_chat_id,
|
||||
content=content,
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("websocket connection ended: {}", e)
|
||||
finally:
|
||||
self._connections.pop(chat_id, None)
|
||||
self._cleanup_connection(connection)
|
||||
|
||||
async def _dispatch_envelope(
|
||||
self,
|
||||
connection: Any,
|
||||
client_id: str,
|
||||
envelope: dict[str, Any],
|
||||
) -> None:
|
||||
"""Route one typed inbound envelope (``new_chat`` / ``attach`` / ``message``)."""
|
||||
t = envelope.get("type")
|
||||
if t == "new_chat":
|
||||
new_id = str(uuid.uuid4())
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(connection, "attached", chat_id=new_id)
|
||||
return
|
||||
if t == "attach":
|
||||
cid = envelope.get("chat_id")
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
return
|
||||
if t == "message":
|
||||
cid = envelope.get("chat_id")
|
||||
content = envelope.get("content")
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
await self._send_event(connection, "error", detail="missing content")
|
||||
return
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
await self._handle_message(
|
||||
sender_id=client_id,
|
||||
chat_id=cid,
|
||||
content=content,
|
||||
metadata={"remote": getattr(connection, "remote_address", None)},
|
||||
)
|
||||
return
|
||||
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
if not self._running:
|
||||
@@ -402,30 +519,31 @@ class WebSocketChannel(BaseChannel):
|
||||
except Exception as e:
|
||||
logger.warning("websocket: server task error during shutdown: {}", e)
|
||||
self._server_task = None
|
||||
self._connections.clear()
|
||||
self._subs.clear()
|
||||
self._conn_chats.clear()
|
||||
self._conn_default.clear()
|
||||
self._issued_tokens.clear()
|
||||
|
||||
async def _safe_send(self, chat_id: str, raw: str, *, label: str = "") -> None:
|
||||
"""Send a raw frame, cleaning up dead connections on ConnectionClosed."""
|
||||
connection = self._connections.get(chat_id)
|
||||
if connection is None:
|
||||
return
|
||||
async def _safe_send_to(self, connection: Any, raw: str, *, label: str = "") -> None:
|
||||
"""Send a raw frame to one connection, cleaning up on ConnectionClosed."""
|
||||
try:
|
||||
await connection.send(raw)
|
||||
except ConnectionClosed:
|
||||
self._connections.pop(chat_id, None)
|
||||
logger.warning("websocket{}connection gone for chat_id={}", label, chat_id)
|
||||
self._cleanup_connection(connection)
|
||||
logger.warning("websocket{}connection gone", label)
|
||||
except Exception as e:
|
||||
logger.error("websocket{}send failed: {}", label, e)
|
||||
raise
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
connection = self._connections.get(msg.chat_id)
|
||||
if connection is None:
|
||||
logger.warning("websocket: no active connection for chat_id={}", msg.chat_id)
|
||||
# Snapshot the subscriber set so ConnectionClosed cleanups mid-iteration are safe.
|
||||
conns = list(self._subs.get(msg.chat_id, ()))
|
||||
if not conns:
|
||||
logger.warning("websocket: no active subscribers for chat_id={}", msg.chat_id)
|
||||
return
|
||||
payload: dict[str, Any] = {
|
||||
"event": "message",
|
||||
"chat_id": msg.chat_id,
|
||||
"text": msg.content,
|
||||
}
|
||||
if msg.media:
|
||||
@@ -433,7 +551,8 @@ class WebSocketChannel(BaseChannel):
|
||||
if msg.reply_to:
|
||||
payload["reply_to"] = msg.reply_to
|
||||
raw = json.dumps(payload, ensure_ascii=False)
|
||||
await self._safe_send(msg.chat_id, raw, label=" ")
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" ")
|
||||
|
||||
async def send_delta(
|
||||
self,
|
||||
@@ -441,17 +560,20 @@ class WebSocketChannel(BaseChannel):
|
||||
delta: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
if self._connections.get(chat_id) is None:
|
||||
conns = list(self._subs.get(chat_id, ()))
|
||||
if not conns:
|
||||
return
|
||||
meta = metadata or {}
|
||||
if meta.get("_stream_end"):
|
||||
body: dict[str, Any] = {"event": "stream_end"}
|
||||
body: dict[str, Any] = {"event": "stream_end", "chat_id": chat_id}
|
||||
else:
|
||||
body = {
|
||||
"event": "delta",
|
||||
"chat_id": chat_id,
|
||||
"text": delta,
|
||||
}
|
||||
if meta.get("_stream_id") is not None:
|
||||
body["stream_id"] = meta["_stream_id"]
|
||||
raw = json.dumps(body, ensure_ascii=False)
|
||||
await self._safe_send(chat_id, raw, label=" stream ")
|
||||
for connection in conns:
|
||||
await self._safe_send_to(connection, raw, label=" stream ")
|
||||
|
||||
@@ -302,13 +302,22 @@ class WecomChannel(BaseChannel):
|
||||
|
||||
elif msg_type == "mixed":
|
||||
# Mixed content contains multiple message items
|
||||
msg_items = body.get("mixed", {}).get("item", [])
|
||||
msg_items = body.get("mixed", {}).get("msg_item", [])
|
||||
for item in msg_items:
|
||||
item_type = item.get("type", "")
|
||||
item_type = item.get("msgtype", "")
|
||||
if item_type == "text":
|
||||
text = item.get("text", {}).get("content", "")
|
||||
if text:
|
||||
content_parts.append(text)
|
||||
elif item_type == "image":
|
||||
file_url = item.get("image", {}).get("url", "")
|
||||
aes_key = item.get("image", {}).get("aeskey", "")
|
||||
if file_url and aes_key:
|
||||
file_path = await self._download_and_save_media(file_url, aes_key, "image")
|
||||
if file_path:
|
||||
filename = os.path.basename(file_path)
|
||||
content_parts.append(f"[image: {filename}]")
|
||||
media_paths.append(file_path)
|
||||
else:
|
||||
content_parts.append(MSG_TYPE_MAP.get(item_type, f"[{item_type}]"))
|
||||
|
||||
|
||||
+49
-2
@@ -593,6 +593,7 @@ def serve(
|
||||
unified_session=runtime_config.agents.defaults.unified_session,
|
||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
||||
tools_config=runtime_config.tools,
|
||||
)
|
||||
|
||||
model_name = runtime_config.agents.defaults.model
|
||||
@@ -687,6 +688,7 @@ def gateway(
|
||||
unified_session=config.agents.defaults.unified_session,
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
|
||||
# Set cron callback (needs agent)
|
||||
@@ -729,7 +731,7 @@ def gateway(
|
||||
response = resp.content if resp else ""
|
||||
|
||||
message_tool = agent.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
if job.payload.deliver and isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
return response
|
||||
|
||||
if job.payload.deliver and job.payload.to and response:
|
||||
@@ -821,12 +823,55 @@ def gateway(
|
||||
|
||||
console.print(f"[green]✓[/green] Heartbeat: every {hb_cfg.interval_s}s")
|
||||
|
||||
async def _health_server(host: str, health_port: int):
|
||||
"""Lightweight HTTP health endpoint on the gateway port."""
|
||||
import json as _json
|
||||
|
||||
async def handle(reader, writer):
|
||||
try:
|
||||
data = await asyncio.wait_for(reader.read(4096), timeout=5)
|
||||
except (asyncio.TimeoutError, ConnectionError):
|
||||
writer.close()
|
||||
return
|
||||
|
||||
request_line = data.split(b"\r\n", 1)[0].decode("utf-8", errors="replace")
|
||||
method, path = "", ""
|
||||
parts = request_line.split(" ")
|
||||
if len(parts) >= 2:
|
||||
method, path = parts[0], parts[1]
|
||||
|
||||
if method == "GET" and path == "/health":
|
||||
body = _json.dumps({"status": "ok"})
|
||||
resp = (
|
||||
f"HTTP/1.0 200 OK\r\n"
|
||||
f"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
f"\r\n{body}"
|
||||
)
|
||||
else:
|
||||
body = "Not Found"
|
||||
resp = (
|
||||
f"HTTP/1.0 404 Not Found\r\n"
|
||||
f"Content-Type: text/plain\r\n"
|
||||
f"Content-Length: {len(body)}\r\n"
|
||||
f"\r\n{body}"
|
||||
)
|
||||
|
||||
writer.write(resp.encode())
|
||||
await writer.drain()
|
||||
writer.close()
|
||||
|
||||
server = await asyncio.start_server(handle, host, health_port)
|
||||
console.print(f"[green]✓[/green] Health endpoint: http://{host}:{health_port}/health")
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
# Register Dream system job (always-on, idempotent on restart)
|
||||
dream_cfg = config.agents.defaults.dream
|
||||
if dream_cfg.model_override:
|
||||
agent.dream.model = dream_cfg.model_override
|
||||
agent.dream.max_batch_size = dream_cfg.max_batch_size
|
||||
agent.dream.max_iterations = dream_cfg.max_iterations
|
||||
agent.dream.annotate_line_ages = dream_cfg.annotate_line_ages
|
||||
from nanobot.cron.types import CronJob, CronPayload
|
||||
cron.register_system_job(CronJob(
|
||||
id="dream",
|
||||
@@ -843,6 +888,7 @@ def gateway(
|
||||
await asyncio.gather(
|
||||
agent.run(),
|
||||
channels.start_all(),
|
||||
_health_server(config.gateway.host, port),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\nShutting down...")
|
||||
@@ -921,6 +967,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,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
if restart_notice and should_show_cli_restart_notice(restart_notice, session_id):
|
||||
@@ -964,7 +1011,7 @@ def agent(
|
||||
# Interactive mode — route through bus like other channels
|
||||
from nanobot.bus.events import InboundMessage
|
||||
_init_prompt_session()
|
||||
console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n")
|
||||
console.print(f"{__logo__} Interactive mode [bold blue]({config.agents.defaults.model})[/bold blue] — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||
|
||||
if ":" in session_id:
|
||||
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||
|
||||
+113
-10
@@ -4,7 +4,7 @@ import json
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, NamedTuple, get_args, get_origin
|
||||
from typing import Any, Literal, NamedTuple, get_args, get_origin
|
||||
|
||||
try:
|
||||
import questionary
|
||||
@@ -202,6 +202,8 @@ def _get_field_type_info(field_info) -> FieldTypeInfo:
|
||||
return FieldTypeInfo(name, None)
|
||||
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
||||
return FieldTypeInfo("model", annotation)
|
||||
if origin is Literal:
|
||||
return FieldTypeInfo("literal", list(args))
|
||||
return FieldTypeInfo("str", None)
|
||||
|
||||
|
||||
@@ -264,7 +266,12 @@ def _format_value(value: Any, rich: bool = True, field_name: str = "") -> str:
|
||||
if isinstance(value, list):
|
||||
return ", ".join(str(v) for v in value)
|
||||
if isinstance(value, dict):
|
||||
return json.dumps(value)
|
||||
# Handle dicts containing BaseModel instances
|
||||
parts = []
|
||||
for k, v in value.items():
|
||||
formatted = _format_value(v, rich=False, field_name=str(k))
|
||||
parts.append(f"{k}: {formatted}")
|
||||
return ", ".join(parts) if parts else ("[dim]not set[/dim]" if rich else "[not set]")
|
||||
return str(value)
|
||||
|
||||
|
||||
@@ -279,6 +286,63 @@ def _format_value_for_input(value: Any, field_type: str) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
def _validate_field_constraint(value: Any, field_info) -> str | None:
|
||||
"""Validate a value against Pydantic Field constraints.
|
||||
|
||||
Returns an error message string if validation fails, None if valid.
|
||||
Uses attribute-based detection to handle Pydantic v2 internal types.
|
||||
"""
|
||||
if field_info is None or not hasattr(field_info, "metadata"):
|
||||
return None
|
||||
|
||||
for m in field_info.metadata:
|
||||
if hasattr(m, "ge") and isinstance(value, (int, float)):
|
||||
if value < m.ge:
|
||||
return f"Value must be >= {m.ge}"
|
||||
if hasattr(m, "gt") and isinstance(value, (int, float)):
|
||||
if value <= m.gt:
|
||||
return f"Value must be > {m.gt}"
|
||||
if hasattr(m, "le") and isinstance(value, (int, float)):
|
||||
if value > m.le:
|
||||
return f"Value must be <= {m.le}"
|
||||
if hasattr(m, "lt") and isinstance(value, (int, float)):
|
||||
if value >= m.lt:
|
||||
return f"Value must be < {m.lt}"
|
||||
if hasattr(m, "min_length") and hasattr(value, "__len__"):
|
||||
if len(value) < m.min_length:
|
||||
return f"Length must be >= {m.min_length}"
|
||||
if hasattr(m, "max_length") and hasattr(value, "__len__"):
|
||||
if len(value) > m.max_length:
|
||||
return f"Length must be <= {m.max_length}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_constraint_hint(field_info) -> str:
|
||||
"""Derive a human-readable constraint hint from field metadata.
|
||||
|
||||
Returns a string like "(0-10)" or "(>= 0)" to append to field display names.
|
||||
"""
|
||||
if field_info is None or not hasattr(field_info, "metadata"):
|
||||
return ""
|
||||
|
||||
ge_val = None
|
||||
le_val = None
|
||||
for m in field_info.metadata:
|
||||
if hasattr(m, "ge"):
|
||||
ge_val = m.ge
|
||||
if hasattr(m, "le"):
|
||||
le_val = m.le
|
||||
|
||||
if ge_val is not None and le_val is not None:
|
||||
return f" ({ge_val}-{le_val})"
|
||||
if ge_val is not None:
|
||||
return f" (>= {ge_val})"
|
||||
if le_val is not None:
|
||||
return f" (<= {le_val})"
|
||||
return ""
|
||||
|
||||
|
||||
# --- Rich UI Components ---
|
||||
|
||||
|
||||
@@ -333,7 +397,7 @@ def _input_bool(display_name: str, current: bool | None) -> bool | None:
|
||||
).ask()
|
||||
|
||||
|
||||
def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
||||
def _input_text(display_name: str, current: Any, field_type: str, field_info=None) -> Any:
|
||||
"""Get text input and parse based on field type."""
|
||||
default = _format_value_for_input(current, field_type)
|
||||
|
||||
@@ -344,16 +408,28 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
||||
|
||||
if field_type == "int":
|
||||
try:
|
||||
return int(value)
|
||||
parsed = int(value)
|
||||
except ValueError:
|
||||
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
||||
return None
|
||||
if field_info:
|
||||
error = _validate_field_constraint(parsed, field_info)
|
||||
if error:
|
||||
console.print(f"[yellow]! {error}, value not saved[/yellow]")
|
||||
return None
|
||||
return parsed
|
||||
elif field_type == "float":
|
||||
try:
|
||||
return float(value)
|
||||
parsed = float(value)
|
||||
except ValueError:
|
||||
console.print("[yellow]! Invalid number format, value not saved[/yellow]")
|
||||
return None
|
||||
if field_info:
|
||||
error = _validate_field_constraint(parsed, field_info)
|
||||
if error:
|
||||
console.print(f"[yellow]! {error}, value not saved[/yellow]")
|
||||
return None
|
||||
return parsed
|
||||
elif field_type == "list":
|
||||
return [v.strip() for v in value.split(",") if v.strip()]
|
||||
elif field_type == "dict":
|
||||
@@ -367,7 +443,7 @@ def _input_text(display_name: str, current: Any, field_type: str) -> Any:
|
||||
|
||||
|
||||
def _input_with_existing(
|
||||
display_name: str, current: Any, field_type: str
|
||||
display_name: str, current: Any, field_type: str, field_info=None
|
||||
) -> Any:
|
||||
"""Handle input with 'keep existing' option for non-empty values."""
|
||||
has_existing = current is not None and current != "" and current != {} and current != []
|
||||
@@ -381,7 +457,7 @@ def _input_with_existing(
|
||||
if choice == "Keep existing value" or choice is None:
|
||||
return None
|
||||
|
||||
return _input_text(display_name, current, field_type)
|
||||
return _input_text(display_name, current, field_type, field_info=field_info)
|
||||
|
||||
|
||||
# --- Pydantic Model Configuration ---
|
||||
@@ -568,7 +644,7 @@ def _configure_pydantic_model(
|
||||
field_name, field_info = fields[field_idx]
|
||||
current_value = getattr(working_model, field_name, None)
|
||||
ftype = _get_field_type_info(field_info)
|
||||
field_display = _get_field_display_name(field_name, field_info)
|
||||
field_display = _get_field_display_name(field_name, field_info) + _get_constraint_hint(field_info)
|
||||
|
||||
# Nested Pydantic model - recurse
|
||||
if ftype.type_name == "model":
|
||||
@@ -607,10 +683,19 @@ def _configure_pydantic_model(
|
||||
continue
|
||||
|
||||
# Generic field input
|
||||
if ftype.type_name == "literal" and ftype.inner_type:
|
||||
select_choices = [str(v) for v in ftype.inner_type]
|
||||
default_choice = str(current_value) if current_value in ftype.inner_type else select_choices[0]
|
||||
new_value = _select_with_back(field_display, select_choices, default=default_choice)
|
||||
if new_value is _BACK_PRESSED:
|
||||
continue
|
||||
if new_value is not None:
|
||||
setattr(working_model, field_name, new_value)
|
||||
continue
|
||||
if ftype.type_name == "bool":
|
||||
new_value = _input_bool(field_display, current_value)
|
||||
else:
|
||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name)
|
||||
new_value = _input_with_existing(field_display, current_value, ftype.type_name, field_info=field_info)
|
||||
if new_value is not None:
|
||||
setattr(working_model, field_name, new_value)
|
||||
|
||||
@@ -821,18 +906,24 @@ def _configure_channels(config: Config) -> None:
|
||||
|
||||
_SETTINGS_SECTIONS: dict[str, tuple[str, str, set[str] | None]] = {
|
||||
"Agent Settings": ("Agent Defaults", "Configure default model, temperature, and behavior", None),
|
||||
"Channel Common": ("Channel Common", "Configure cross-channel behavior: progress, tool hints, retries", None),
|
||||
"API Server": ("API Server", "Configure OpenAI-compatible API endpoint", None),
|
||||
"Gateway": ("Gateway Settings", "Configure server host, port, and heartbeat", None),
|
||||
"Tools": ("Tools Settings", "Configure web search, shell exec, and other tools", {"mcp_servers"}),
|
||||
}
|
||||
|
||||
_SETTINGS_GETTER = {
|
||||
"Agent Settings": lambda c: c.agents.defaults,
|
||||
"Channel Common": lambda c: c.channels,
|
||||
"API Server": lambda c: c.api,
|
||||
"Gateway": lambda c: c.gateway,
|
||||
"Tools": lambda c: c.tools,
|
||||
}
|
||||
|
||||
_SETTINGS_SETTER = {
|
||||
"Agent Settings": lambda c, v: setattr(c.agents, "defaults", v),
|
||||
"Channel Common": lambda c, v: setattr(c, "channels", v),
|
||||
"API Server": lambda c, v: setattr(c, "api", v),
|
||||
"Gateway": lambda c, v: setattr(c, "gateway", v),
|
||||
"Tools": lambda c, v: setattr(c, "tools", v),
|
||||
}
|
||||
@@ -915,12 +1006,20 @@ def _show_summary(config: Config) -> None:
|
||||
# Settings sections
|
||||
for title, model in [
|
||||
("Agent Settings", config.agents.defaults),
|
||||
("Channel Common", config.channels),
|
||||
("API Server", config.api),
|
||||
("Gateway", config.gateway),
|
||||
("Tools", config.tools),
|
||||
("Channel Common", config.channels),
|
||||
]:
|
||||
_print_summary_panel(_summarize_model(model), title)
|
||||
|
||||
_pause()
|
||||
|
||||
|
||||
def _pause() -> None:
|
||||
"""Pause for user acknowledgement before clearing the screen."""
|
||||
_get_questionary().text("Press Enter to continue...", default="").ask()
|
||||
|
||||
|
||||
# --- Main Entry Point ---
|
||||
|
||||
@@ -984,7 +1083,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
||||
choices=[
|
||||
"[P] LLM Provider",
|
||||
"[C] Chat Channel",
|
||||
"[H] Channel Common",
|
||||
"[A] Agent Settings",
|
||||
"[I] API Server",
|
||||
"[G] Gateway",
|
||||
"[T] Tools",
|
||||
"[V] View Configuration Summary",
|
||||
@@ -1007,7 +1108,9 @@ def run_onboard(initial_config: Config | None = None) -> OnboardResult:
|
||||
_MENU_DISPATCH = {
|
||||
"[P] LLM Provider": lambda: _configure_providers(config),
|
||||
"[C] Chat Channel": lambda: _configure_channels(config),
|
||||
"[H] Channel Common": lambda: _configure_general_settings(config, "Channel Common"),
|
||||
"[A] Agent Settings": lambda: _configure_general_settings(config, "Agent Settings"),
|
||||
"[I] API Server": lambda: _configure_general_settings(config, "API Server"),
|
||||
"[G] Gateway": lambda: _configure_general_settings(config, "Gateway"),
|
||||
"[T] Tools": lambda: _configure_general_settings(config, "Tools"),
|
||||
"[V] View Configuration Summary": lambda: _show_summary(config),
|
||||
|
||||
@@ -102,7 +102,7 @@ class StreamRenderer:
|
||||
self._live = Live(self._render(), console=c, auto_refresh=False)
|
||||
self._live.start()
|
||||
now = time.monotonic()
|
||||
if "\n" in delta or (now - self._t) > 0.05:
|
||||
if (now - self._t) > 0.15:
|
||||
self._live.update(self._render())
|
||||
self._live.refresh()
|
||||
self._t = now
|
||||
|
||||
@@ -74,6 +74,12 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
search_usage_text = usage.format()
|
||||
except Exception:
|
||||
pass # Never let usage fetch break /status
|
||||
active_tasks = loop._active_tasks.get(ctx.key, [])
|
||||
task_count = sum(1 for t in active_tasks if not t.done())
|
||||
try:
|
||||
task_count += loop.subagents.get_running_count_by_session(ctx.key)
|
||||
except Exception:
|
||||
pass
|
||||
return OutboundMessage(
|
||||
channel=ctx.msg.channel,
|
||||
chat_id=ctx.msg.chat_id,
|
||||
@@ -84,6 +90,10 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
|
||||
session_msg_count=len(session.get_history(max_messages=0)),
|
||||
context_tokens_estimate=ctx_est,
|
||||
search_usage_text=search_usage_text,
|
||||
active_task_count=task_count,
|
||||
max_completion_tokens=getattr(
|
||||
getattr(loop.provider, "generation", None), "max_tokens", 8192
|
||||
),
|
||||
),
|
||||
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
|
||||
)
|
||||
|
||||
@@ -117,4 +117,19 @@ def _migrate_config(data: dict) -> dict:
|
||||
exec_cfg = tools.get("exec", {})
|
||||
if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools:
|
||||
tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace")
|
||||
|
||||
# Move tools.myEnabled / tools.mySet → tools.my.{enable, allowSet}.
|
||||
# The old flat keys shipped in the initial MyTool landing; wrapping them in a
|
||||
# sub-config keeps `web` / `exec` / `my` symmetric and gives room to grow.
|
||||
if "myEnabled" in tools or "mySet" in tools:
|
||||
my_cfg = tools.setdefault("my", {})
|
||||
if "myEnabled" in tools and "enable" not in my_cfg:
|
||||
my_cfg["enable"] = tools.pop("myEnabled")
|
||||
else:
|
||||
tools.pop("myEnabled", None)
|
||||
if "mySet" in tools and "allowSet" not in my_cfg:
|
||||
my_cfg["allowSet"] = tools.pop("mySet")
|
||||
else:
|
||||
tools.pop("mySet", None)
|
||||
|
||||
return data
|
||||
|
||||
@@ -43,7 +43,12 @@ class DreamConfig(Base):
|
||||
validation_alias=AliasChoices("modelOverride", "model", "model_override"),
|
||||
) # Optional Dream-specific model override
|
||||
max_batch_size: int = Field(default=20, ge=1) # Max history entries per run
|
||||
max_iterations: int = Field(default=10, ge=1) # Max tool calls per Phase 2
|
||||
# Bumped from 10 to 15 in #3212 (exp002: +30% dedup, no accuracy loss; >15 plateaus).
|
||||
max_iterations: int = Field(default=15, ge=1) # Max tool calls per Phase 2
|
||||
# Per-line git-blame age annotation in Phase 1 prompt (see #3212). Default
|
||||
# on — set to False to feed MEMORY.md raw if a specific LLM reacts poorly
|
||||
# to the `← Nd` suffix or you want deterministic, git-independent prompts.
|
||||
annotate_line_ages: bool = True
|
||||
|
||||
def build_schedule(self, timezone: str) -> CronSchedule:
|
||||
"""Build the runtime schedule, preferring the legacy cron override if present."""
|
||||
@@ -96,7 +101,7 @@ class AgentsConfig(Base):
|
||||
class ProviderConfig(Base):
|
||||
"""LLM provider configuration."""
|
||||
|
||||
api_key: str = ""
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
|
||||
|
||||
@@ -115,10 +120,12 @@ class ProvidersConfig(Base):
|
||||
dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
ollama: ProviderConfig = Field(default_factory=ProviderConfig) # Ollama local models
|
||||
lm_studio: ProviderConfig = Field(default_factory=ProviderConfig) # LM Studio local models
|
||||
ovms: ProviderConfig = Field(default_factory=ProviderConfig) # OpenVINO Model Server (OVMS)
|
||||
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
minimax_anthropic: ProviderConfig = Field(default_factory=ProviderConfig) # MiniMax Anthropic endpoint (thinking)
|
||||
mistral: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||
stepfun: ProviderConfig = Field(default_factory=ProviderConfig) # Step Fun (阶跃星辰)
|
||||
xiaomi_mimo: ProviderConfig = Field(default_factory=ProviderConfig) # Xiaomi MIMO (小米)
|
||||
@@ -152,7 +159,7 @@ class ApiConfig(Base):
|
||||
class GatewayConfig(Base):
|
||||
"""Gateway/server configuration."""
|
||||
|
||||
host: str = "0.0.0.0"
|
||||
host: str = "127.0.0.1" # Safer default: local-only bind.
|
||||
port: int = 18790
|
||||
heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)
|
||||
|
||||
@@ -198,11 +205,19 @@ class MCPServerConfig(Base):
|
||||
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||
enabled_tools: list[str] = Field(default_factory=lambda: ["*"]) # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools
|
||||
|
||||
class MyToolConfig(Base):
|
||||
"""Self-inspection tool configuration."""
|
||||
|
||||
enable: bool = True # register the `my` tool (agent runtime state inspection)
|
||||
allow_set: bool = False # let `my` modify loop state (read-only if False)
|
||||
|
||||
|
||||
class ToolsConfig(Base):
|
||||
"""Tools configuration."""
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=WebToolsConfig)
|
||||
exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
|
||||
my: MyToolConfig = Field(default_factory=MyToolConfig)
|
||||
restrict_to_workspace: bool = False # restrict all tool access to workspace directory
|
||||
mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
|
||||
ssrf_whitelist: list[str] = Field(default_factory=list) # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)
|
||||
|
||||
@@ -104,7 +104,12 @@ class HeartbeatService:
|
||||
model=self.model,
|
||||
)
|
||||
|
||||
if not response.has_tool_calls:
|
||||
if not response.should_execute_tools:
|
||||
if response.has_tool_calls:
|
||||
logger.warning(
|
||||
"Ignoring heartbeat tool calls under finish_reason='{}'",
|
||||
response.finish_reason,
|
||||
)
|
||||
return "skip", ""
|
||||
|
||||
args = response.tool_calls[0].arguments
|
||||
|
||||
@@ -84,6 +84,7 @@ class Nanobot:
|
||||
unified_session=defaults.unified_session,
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
return cls(loop)
|
||||
|
||||
|
||||
@@ -67,6 +67,14 @@ class LLMResponse:
|
||||
"""Check if response contains tool calls."""
|
||||
return len(self.tool_calls) > 0
|
||||
|
||||
@property
|
||||
def should_execute_tools(self) -> bool:
|
||||
"""Tools execute only when has_tool_calls AND finish_reason is ``tool_calls`` / ``stop``.
|
||||
Blocks gateway-injected calls under ``refusal`` / ``content_filter`` / ``error`` (#3220)."""
|
||||
if not self.has_tool_calls:
|
||||
return False
|
||||
return self.finish_reason in ("tool_calls", "stop")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationSettings:
|
||||
@@ -77,6 +85,9 @@ class GenerationSettings:
|
||||
reasoning_effort: str | None = None
|
||||
|
||||
|
||||
_SYNTHETIC_USER_CONTENT = "(conversation continued)"
|
||||
|
||||
|
||||
class LLMProvider(ABC):
|
||||
"""Base class for LLM providers."""
|
||||
|
||||
@@ -409,6 +420,17 @@ class LLMProvider(ABC):
|
||||
recovered["role"] = "user"
|
||||
merged.append(recovered)
|
||||
|
||||
# Safety net: ensure the first non-system message is not a bare
|
||||
# ``assistant`` message. Providers like GLM reject system→assistant
|
||||
# with error 1214. This can happen when upstream truncation (e.g.
|
||||
# _snip_history) drops the only user message. Insert a synthetic
|
||||
# user message to keep the sequence valid.
|
||||
for i, msg in enumerate(merged):
|
||||
if msg.get("role") != "system":
|
||||
if msg.get("role") == "assistant" and not msg.get("tool_calls"):
|
||||
merged.insert(i, {"role": "user", "content": _SYNTHETIC_USER_CONTENT})
|
||||
break
|
||||
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
@@ -512,9 +534,9 @@ class LLMProvider(ABC):
|
||||
on_retry_wait: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> LLMResponse:
|
||||
"""Call chat_stream() with retry on transient provider failures."""
|
||||
if max_tokens is self._SENTINEL:
|
||||
if max_tokens is self._SENTINEL or max_tokens is None:
|
||||
max_tokens = self.generation.max_tokens
|
||||
if temperature is self._SENTINEL:
|
||||
if temperature is self._SENTINEL or temperature is None:
|
||||
temperature = self.generation.temperature
|
||||
if reasoning_effort is self._SENTINEL:
|
||||
reasoning_effort = self.generation.reasoning_effort
|
||||
@@ -549,11 +571,14 @@ class LLMProvider(ABC):
|
||||
|
||||
Parameters default to ``self.generation`` when not explicitly passed,
|
||||
so callers no longer need to thread temperature / max_tokens /
|
||||
reasoning_effort through every layer.
|
||||
reasoning_effort through every layer. Explicit ``None`` is also
|
||||
normalized to the provider's generation defaults so that downstream
|
||||
``_build_kwargs`` never sees ``None`` for ``max_tokens`` / ``temperature``
|
||||
(which would crash ``max(1, max_tokens)``).
|
||||
"""
|
||||
if max_tokens is self._SENTINEL:
|
||||
if max_tokens is self._SENTINEL or max_tokens is None:
|
||||
max_tokens = self.generation.max_tokens
|
||||
if temperature is self._SENTINEL:
|
||||
if temperature is self._SENTINEL or temperature is None:
|
||||
temperature = self.generation.temperature
|
||||
if reasoning_effort is self._SENTINEL:
|
||||
reasoning_effort = self.generation.reasoning_effort
|
||||
@@ -718,9 +743,22 @@ class LLMProvider(ABC):
|
||||
identical_error_count,
|
||||
(response.content or "")[:120].lower(),
|
||||
)
|
||||
if on_retry_wait:
|
||||
await on_retry_wait(
|
||||
f"Persistent retry stopped after {identical_error_count} identical errors."
|
||||
)
|
||||
return response
|
||||
|
||||
if not persistent and attempt > len(delays):
|
||||
logger.warning(
|
||||
"LLM request failed after {} retries, giving up: {}",
|
||||
attempt,
|
||||
(response.content or "")[:120].lower(),
|
||||
)
|
||||
if on_retry_wait:
|
||||
await on_retry_wait(
|
||||
f"Model request failed after {attempt} retries, giving up."
|
||||
)
|
||||
break
|
||||
|
||||
base_delay = delays[min(attempt - 1, len(delays) - 1)]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
@@ -49,6 +50,29 @@ _DEFAULT_OPENROUTER_HEADERS = {
|
||||
"X-OpenRouter-Title": "nanobot",
|
||||
"X-OpenRouter-Categories": "cli-agent,personal-agent",
|
||||
}
|
||||
_KIMI_THINKING_MODELS: frozenset[str] = frozenset({
|
||||
"kimi-k2.5",
|
||||
"k2.6-code-preview",
|
||||
})
|
||||
|
||||
|
||||
def _is_kimi_thinking_model(model_name: str) -> bool:
|
||||
"""Return True if model_name refers to a Kimi thinking-capable model.
|
||||
|
||||
Supports two forms:
|
||||
- Exact match: kimi-k2.5 in _KIMI_THINKING_MODELS
|
||||
- Slug match: moonshotai/kimi-k2.5 -> the part after the last "/"
|
||||
is checked against _KIMI_THINKING_MODELS
|
||||
|
||||
This covers both the native Moonshot provider (bare slug) and
|
||||
OpenRouter-style names (``"publisher/slug"``).
|
||||
"""
|
||||
name = model_name.lower()
|
||||
if name in _KIMI_THINKING_MODELS:
|
||||
return True
|
||||
if "/" in name and name.rsplit("/", 1)[1] in _KIMI_THINKING_MODELS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
@@ -222,6 +246,24 @@ class OpenAICompatProvider(LLMProvider):
|
||||
return tool_call_id
|
||||
return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_tool_call_arguments(arguments: Any) -> str:
|
||||
"""Force function.arguments into a valid JSON object string."""
|
||||
if isinstance(arguments, str):
|
||||
stripped = arguments.strip()
|
||||
if not stripped:
|
||||
return "{}"
|
||||
try:
|
||||
parsed = json_repair.loads(stripped)
|
||||
except Exception:
|
||||
return "{}"
|
||||
if isinstance(parsed, dict):
|
||||
return json.dumps(parsed, ensure_ascii=False)
|
||||
return "{}"
|
||||
if isinstance(arguments, dict):
|
||||
return json.dumps(arguments, ensure_ascii=False)
|
||||
return "{}"
|
||||
|
||||
def _sanitize_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys, normalize tool_call IDs."""
|
||||
sanitized = LLMProvider._sanitize_request_messages(messages, _ALLOWED_MSG_KEYS)
|
||||
@@ -241,6 +283,16 @@ class OpenAICompatProvider(LLMProvider):
|
||||
continue
|
||||
tc_clean = dict(tc)
|
||||
tc_clean["id"] = map_id(tc_clean.get("id"))
|
||||
function = tc_clean.get("function")
|
||||
if isinstance(function, dict):
|
||||
function_clean = dict(function)
|
||||
if "arguments" in function_clean:
|
||||
function_clean["arguments"] = self._normalize_tool_call_arguments(
|
||||
function_clean.get("arguments")
|
||||
)
|
||||
else:
|
||||
function_clean["arguments"] = "{}"
|
||||
tc_clean["function"] = function_clean
|
||||
normalized.append(tc_clean)
|
||||
clean["tool_calls"] = normalized
|
||||
if clean.get("role") == "assistant":
|
||||
@@ -334,6 +386,16 @@ class OpenAICompatProvider(LLMProvider):
|
||||
if extra:
|
||||
kwargs.setdefault("extra_body", {}).update(extra)
|
||||
|
||||
# Model-level thinking injection for Kimi thinking-capable models.
|
||||
# Strip any provider prefix (e.g. "moonshotai/") before the set lookup
|
||||
# so that OpenRouter-style names like "moonshotai/kimi-k2.5" are handled
|
||||
# identically to bare names like "kimi-k2.5".
|
||||
if reasoning_effort is not None and _is_kimi_thinking_model(model_name):
|
||||
thinking_enabled = reasoning_effort.lower() != "minimal"
|
||||
kwargs.setdefault("extra_body", {}).update(
|
||||
{"thinking": {"type": "enabled" if thinking_enabled else "disabled"}}
|
||||
)
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
kwargs["tool_choice"] = tool_choice or "auto"
|
||||
|
||||
@@ -280,6 +280,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
backend="openai_compat",
|
||||
default_api_base="https://api.minimax.io/v1",
|
||||
),
|
||||
# MiniMax Anthropic-compatible endpoint: supports thinking mode
|
||||
ProviderSpec(
|
||||
name="minimax_anthropic",
|
||||
keywords=("minimax_anthropic",),
|
||||
env_key="MINIMAX_API_KEY",
|
||||
display_name="MiniMax (Anthropic)",
|
||||
backend="anthropic",
|
||||
default_api_base="https://api.minimax.io/anthropic",
|
||||
),
|
||||
# Mistral AI: OpenAI-compatible API
|
||||
ProviderSpec(
|
||||
name="mistral",
|
||||
@@ -328,6 +337,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
|
||||
detect_by_base_keyword="11434",
|
||||
default_api_base="http://localhost:11434/v1",
|
||||
),
|
||||
# LM Studio (local, OpenAI-compatible)
|
||||
ProviderSpec(
|
||||
name="lm_studio",
|
||||
keywords=("lm-studio", "lmstudio", "lm_studio"),
|
||||
env_key="LM_STUDIO_API_KEY",
|
||||
display_name="LM Studio",
|
||||
backend="openai_compat",
|
||||
is_local=True,
|
||||
detect_by_base_keyword="1234",
|
||||
default_api_base="http://localhost:1234/v1",
|
||||
),
|
||||
# === OpenVINO Model Server (direct, local, OpenAI-compatible at /v3) ===
|
||||
ProviderSpec(
|
||||
name="ovms",
|
||||
|
||||
@@ -10,9 +10,13 @@ from loguru import logger
|
||||
class OpenAITranscriptionProvider:
|
||||
"""Voice transcription provider using OpenAI's Whisper API."""
|
||||
|
||||
def __init__(self, api_key: str | None = None):
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
self.api_key = api_key or os.environ.get("OPENAI_API_KEY")
|
||||
self.api_url = "https://api.openai.com/v1/audio/transcriptions"
|
||||
self.api_url = (
|
||||
api_base
|
||||
or os.environ.get("OPENAI_TRANSCRIPTION_BASE_URL")
|
||||
or "https://api.openai.com/v1/audio/transcriptions"
|
||||
)
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
if not self.api_key:
|
||||
@@ -44,9 +48,9 @@ class GroqTranscriptionProvider:
|
||||
Groq offers extremely fast transcription with a generous free tier.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str | None = None):
|
||||
def __init__(self, api_key: str | None = None, api_base: str | None = None):
|
||||
self.api_key = api_key or os.environ.get("GROQ_API_KEY")
|
||||
self.api_url = "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
self.api_url = api_base or os.environ.get("GROQ_BASE_URL") or "https://api.groq.com/openai/v1/audio/transcriptions"
|
||||
|
||||
async def transcribe(self, file_path: str | Path) -> str:
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: my
|
||||
description: Check and set the agent's own runtime state (model, iterations, context window, token usage, web config). Use when diagnosing why something doesn't work ("why can't you search the web?", "why did you stop?"), checking resource limits before complex tasks, adapting configuration for long or simple tasks, or remembering user preferences across turns. Also use when the user asks what model you are running, how many tokens you've used, or what your settings are.
|
||||
always: true
|
||||
---
|
||||
|
||||
# Self-Awareness
|
||||
|
||||
## How to use
|
||||
|
||||
1. **Identify the situation** from the categories below
|
||||
2. **Call the my tool** with the appropriate action
|
||||
3. **If set**, warn the user before changing impactful settings (model, iterations)
|
||||
4. **For detailed examples**, read [references/examples.md](references/examples.md)
|
||||
|
||||
## When to check
|
||||
|
||||
<rule>
|
||||
**Diagnose before explaining.** When something doesn't work, check your state first.
|
||||
</rule>
|
||||
|
||||
<rule>
|
||||
**Check budget before complex tasks.** Know your limits before committing.
|
||||
</rule>
|
||||
|
||||
<rule>
|
||||
**Recall across turns.** Store preferences in your scratchpad, read them back later.
|
||||
</rule>
|
||||
|
||||
## When to set
|
||||
|
||||
<rule>
|
||||
**Only set when benefit is clear and user is informed.** Warn before changing model.
|
||||
</rule>
|
||||
|
||||
| Situation | Command |
|
||||
|-----------|---------|
|
||||
| Large codebase analysis | `my(action="set", key="context_window_tokens", value=131072)` |
|
||||
| Repetitive simple tasks | `my(action="set", key="model", value="<fast-model>")` |
|
||||
| Long multi-step task | `my(action="set", key="max_iterations", value=80)` |
|
||||
|
||||
**Tradeoff:** Bias toward stability. Only set when defaults are genuinely insufficient.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
<rule>
|
||||
**Don't check every turn.** Costs a tool call. Use when you need information, not reflexively.
|
||||
</rule>
|
||||
|
||||
<rule>
|
||||
**Don't store sensitive data.** No API keys, passwords, or tokens in scratchpad.
|
||||
</rule>
|
||||
|
||||
<rule>
|
||||
**Don't set workspace.** Does not update file tool boundaries — won't work.
|
||||
</rule>
|
||||
|
||||
## Constraints
|
||||
|
||||
- All modifications in-memory only — restart resets everything
|
||||
- Protected params have type/range validation: `max_iterations` (1–100), `context_window_tokens` (4096–1M), `model` (non-empty str)
|
||||
- If `tools.my.allow_set` is false, check only
|
||||
|
||||
## Related tools
|
||||
|
||||
| Need | Use | Persists? |
|
||||
|------|-----|-----------|
|
||||
| Per-session temp state | `my(action="set", key="...", value=...)` | No |
|
||||
| Long-term facts | Memory skill (`MEMORY.md`, `USER.md`) | Yes |
|
||||
| Permanent config change | Edit config file | Yes |
|
||||
|
||||
**Rule of thumb:** Tomorrow? Memory. This turn only? My.
|
||||
@@ -0,0 +1,75 @@
|
||||
# My Tool — Practical Examples
|
||||
|
||||
Concrete scenarios showing when and how to use the my tool effectively.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
### "Why can't you search the web?"
|
||||
```
|
||||
→ my(action="check", key="web_config.enable")
|
||||
→ False
|
||||
→ "Web search is disabled. Add web.enable: true to your config to enable it."
|
||||
```
|
||||
|
||||
### "Why did you stop?"
|
||||
```
|
||||
→ my(action="check", key="max_iterations")
|
||||
→ 40
|
||||
→ my(action="check", key="_last_usage")
|
||||
→ {"prompt_tokens": 62000, "completion_tokens": 3000}
|
||||
→ "I hit the iteration limit (40). The task was complex. I can ask the user if they want to increase it."
|
||||
```
|
||||
|
||||
### "What model are you running?"
|
||||
```
|
||||
→ my(action="check", key="model")
|
||||
→ 'anthropic/claude-sonnet-4-20250514'
|
||||
```
|
||||
|
||||
## Adaptive Behavior
|
||||
|
||||
### Large codebase analysis
|
||||
```
|
||||
→ my(action="check")
|
||||
→ context_window_tokens: 65536
|
||||
→ my(action="set", key="context_window_tokens", value=131072)
|
||||
→ "Set context_window_tokens = 131072 (was 65536)"
|
||||
→ "I've expanded my context window to handle this large codebase."
|
||||
```
|
||||
|
||||
### Switching to a faster model for repetitive tasks
|
||||
```
|
||||
→ my(action="set", key="model", value="anthropic/claude-haiku-4-5-20251001")
|
||||
→ "Set model = 'anthropic/claude-haiku-4-5-20251001' (was 'anthropic/claude-sonnet-4-20250514')"
|
||||
→ "Switched to a faster model for these batch tasks."
|
||||
```
|
||||
|
||||
## Cross-Turn Memory
|
||||
|
||||
### Remembering user preferences
|
||||
```
|
||||
# Turn 1: user says "keep it brief"
|
||||
→ my(action="set", key="user_style", value="concise")
|
||||
→ "Set scratchpad.user_style = 'concise'"
|
||||
|
||||
# Turn 3: new topic
|
||||
→ my(action="check", key="user_style")
|
||||
→ 'concise'
|
||||
(adjusts response style accordingly)
|
||||
```
|
||||
|
||||
### Tracking project context
|
||||
```
|
||||
→ my(action="set", key="active_branch", value="feat/auth")
|
||||
→ my(action="set", key="test_framework", value="pytest")
|
||||
→ my(action="set", key="has_docker", value=true)
|
||||
```
|
||||
|
||||
## Budget Awareness
|
||||
|
||||
### Token-conscious behavior
|
||||
```
|
||||
→ my(action="check", key="_last_usage")
|
||||
→ {"prompt_tokens": 58000, "completion_tokens": 12000}
|
||||
→ "I've consumed ~70k tokens. I'll keep my remaining responses focused."
|
||||
```
|
||||
@@ -2,8 +2,19 @@
|
||||
|
||||
I am nanobot 🐈, a personal AI assistant.
|
||||
|
||||
I solve problems by doing, not by describing what I would do.
|
||||
I keep responses short unless depth is asked for.
|
||||
I say what I know, flag what I don't, and never fake confidence.
|
||||
I stay friendly and curious — I'd rather ask a good question than guess wrong.
|
||||
I treat the user's time as the scarcest resource, and their trust as the most valuable.
|
||||
## Core Principles
|
||||
|
||||
- Solve by doing, not by describing what I would do.
|
||||
- Keep responses short unless depth is asked for.
|
||||
- Say what I know, flag what I don't, and never fake confidence.
|
||||
- Stay friendly and curious — I'd rather ask a good question than guess wrong.
|
||||
- Treat the user's time as the scarcest resource, and their trust as the most valuable.
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Act immediately on single-step tasks — never end a turn with just a plan or promise.
|
||||
- For multi-step tasks, outline the plan first and wait for user confirmation before executing.
|
||||
- Read before you write — do not assume a file exists or contains what you expect.
|
||||
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
|
||||
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
|
||||
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
Compare conversation history against current memory files. Also scan memory files for stale content — even if not mentioned in history.
|
||||
You have TWO equally important tasks:
|
||||
1. Extract new facts from conversation history
|
||||
2. Deduplicate existing memory files — find and flag redundant, overlapping, or stale content even if NOT mentioned in history
|
||||
|
||||
Output one line per finding:
|
||||
[FILE] atomic fact (not already in memory)
|
||||
@@ -12,12 +14,20 @@ Rules:
|
||||
- Corrections: [USER] location is Tokyo, not Osaka
|
||||
- Capture confirmed approaches the user validated
|
||||
|
||||
Staleness — flag for [FILE-REMOVE]:
|
||||
- Time-sensitive data older than 14 days: weather, daily status, one-time meetings, passed events
|
||||
- Completed one-time tasks: triage, one-time reviews, finished research, resolved incidents
|
||||
- Resolved tracking: merged/closed PRs, fixed issues, completed migrations
|
||||
- Detailed incident info after 14 days — reduce to one-line summary
|
||||
- Superseded: approaches replaced by newer solutions, deprecated dependencies
|
||||
Deduplication — scan ALL memory files for these redundancy patterns:
|
||||
- Same fact stated in multiple places (e.g., "communicates in Chinese" in both USER.md and multiple MEMORY.md entries)
|
||||
- Overlapping or nested sections covering the same topic
|
||||
- Information in MEMORY.md that is already captured in USER.md or SOUL.md (MEMORY.md should not duplicate permanent-file content)
|
||||
- Verbose entries that can be condensed without losing information
|
||||
For each duplicate found, output [FILE-REMOVE] for the less authoritative copy (prefer keeping facts in their canonical location)
|
||||
|
||||
Staleness — MEMORY.md lines may have a ``← Nd`` suffix showing days since last modification:
|
||||
- SOUL.md and USER.md have no age annotations — they are permanent, only update with corrections
|
||||
- Age only indicates when content was last touched, not whether it should be removed
|
||||
- Use content judgment: user habits/preferences/personality traits are permanent regardless of age
|
||||
- Only prune content that is objectively outdated: passed events, resolved tracking, superseded approaches
|
||||
- Lines with ``← Nd`` (N>{{ stale_threshold_days }}) deserve closer review but are NOT automatically removable
|
||||
- When removing: prefer deleting individual items over entire sections
|
||||
|
||||
Skill discovery — flag [SKILL] when ALL of these are true:
|
||||
- A specific, repeatable workflow appeared 2+ times in the conversation history
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
# nanobot 🐈
|
||||
|
||||
You are nanobot, a helpful AI assistant.
|
||||
|
||||
## Runtime
|
||||
{{ runtime }}
|
||||
|
||||
@@ -26,14 +22,6 @@ This conversation is via email. Structure with clear sections. Markdown may not
|
||||
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
|
||||
{% endif %}
|
||||
|
||||
## Execution Rules
|
||||
|
||||
- Act, don't narrate. If you can do it with a tool, do it now — never end a turn with just a plan or promise.
|
||||
- Read before you write. Do not assume a file exists or contains what you expect.
|
||||
- If a tool call fails, diagnose the error and retry with a different approach before reporting failure.
|
||||
- When information is missing, look it up with tools first. Only ask the user when tools cannot answer.
|
||||
- After multi-step changes, verify the result (re-read the file, run the test, check the output).
|
||||
|
||||
## Search & Discovery
|
||||
|
||||
- Prefer built-in `grep` / `glob` over `exec` for workspace search.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Skills
|
||||
|
||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
||||
Skills with available="false" need dependencies installed first - you can try installing them with apt/brew.
|
||||
Unavailable skills need dependencies installed first — you can try installing them with apt/brew.
|
||||
|
||||
{{ skills_summary }}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"""Document text extraction utilities for nanobot."""
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.utils.helpers import detect_image_mime
|
||||
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
PdfReader = None # type: ignore
|
||||
|
||||
try:
|
||||
from docx import Document as DocxDocument
|
||||
except ImportError:
|
||||
DocxDocument = None # type: ignore
|
||||
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
load_workbook = None # type: ignore
|
||||
|
||||
try:
|
||||
from pptx import Presentation as PptxPresentation
|
||||
except ImportError:
|
||||
PptxPresentation = None # type: ignore
|
||||
|
||||
|
||||
# Supported file extensions for text extraction
|
||||
SUPPORTED_EXTENSIONS: set[str] = {
|
||||
# Document formats
|
||||
".pdf",
|
||||
".docx",
|
||||
".xlsx",
|
||||
".pptx",
|
||||
# Text formats
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".json",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".log",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
# Image formats (for future OCR support)
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".gif",
|
||||
".webp",
|
||||
}
|
||||
|
||||
_MAX_TEXT_LENGTH = 200_000
|
||||
|
||||
|
||||
def extract_text(path: Path) -> str | None:
|
||||
"""Extract text from a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file.
|
||||
|
||||
Returns:
|
||||
Extracted text as string, None for unsupported types,
|
||||
or error string for failures.
|
||||
"""
|
||||
if not isinstance(path, Path):
|
||||
path = Path(path)
|
||||
|
||||
if not path.exists():
|
||||
return f"[error: file not found: {path}]"
|
||||
|
||||
ext = path.suffix.lower()
|
||||
|
||||
# Document formats
|
||||
if ext == ".pdf":
|
||||
if PdfReader is None:
|
||||
return "[error: pypdf not installed]"
|
||||
return _extract_pdf(path)
|
||||
elif ext == ".docx":
|
||||
if DocxDocument is None:
|
||||
return "[error: python-docx not installed]"
|
||||
return _extract_docx(path)
|
||||
elif ext == ".xlsx":
|
||||
if load_workbook is None:
|
||||
return "[error: openpyxl not installed]"
|
||||
return _extract_xlsx(path)
|
||||
elif ext == ".pptx":
|
||||
if PptxPresentation is None:
|
||||
return "[error: python-pptx not installed]"
|
||||
return _extract_pptx(path)
|
||||
elif _is_text_extension(ext):
|
||||
return _extract_text_file(path)
|
||||
elif ext in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
|
||||
# Image files - for future OCR support
|
||||
return f"[image: {path.name}]"
|
||||
else:
|
||||
# Unsupported extension
|
||||
return None
|
||||
|
||||
|
||||
def _extract_pdf(path: Path) -> str:
|
||||
"""Extract text from PDF using pypdf."""
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages: list[str] = []
|
||||
for i, page in enumerate(reader.pages, 1):
|
||||
text = page.extract_text() or ""
|
||||
pages.append(f"--- Page {i} ---\n{text}")
|
||||
return _truncate("\n\n".join(pages), _MAX_TEXT_LENGTH)
|
||||
except Exception as e:
|
||||
logger.error("Failed to extract PDF {}: {}", path, e)
|
||||
return f"[error: failed to extract PDF: {e!s}]"
|
||||
|
||||
|
||||
def _extract_docx(path: Path) -> str:
|
||||
"""Extract text from DOCX using python-docx."""
|
||||
try:
|
||||
doc = DocxDocument(path)
|
||||
paragraphs: list[str] = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||
return _truncate("\n\n".join(paragraphs), _MAX_TEXT_LENGTH)
|
||||
except Exception as e:
|
||||
logger.error("Failed to extract DOCX {}: {}", path, e)
|
||||
return f"[error: failed to extract DOCX: {e!s}]"
|
||||
|
||||
|
||||
def _extract_xlsx(path: Path) -> str:
|
||||
"""Extract text from XLSX using openpyxl."""
|
||||
try:
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
sheets: list[str] = []
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
rows: list[str] = []
|
||||
for row in ws.iter_rows(values_only=True):
|
||||
row_text = "\t".join(str(cell) if cell is not None else "" for cell in row)
|
||||
if row_text.strip():
|
||||
rows.append(row_text)
|
||||
if rows:
|
||||
sheets.append(f"--- Sheet: {sheet_name} ---\n" + "\n".join(rows))
|
||||
wb.close()
|
||||
return _truncate("\n\n".join(sheets), _MAX_TEXT_LENGTH)
|
||||
except Exception as e:
|
||||
logger.error("Failed to extract XLSX {}: {}", path, e)
|
||||
return f"[error: failed to extract XLSX: {e!s}]"
|
||||
|
||||
|
||||
def _extract_pptx(path: Path) -> str:
|
||||
"""Extract text from PPTX using python-pptx."""
|
||||
try:
|
||||
prs = PptxPresentation(path)
|
||||
slides: list[str] = []
|
||||
for i, slide in enumerate(prs.slides, 1):
|
||||
slide_text: list[str] = []
|
||||
for shape in slide.shapes:
|
||||
_collect_pptx_shape_text(shape, slide_text)
|
||||
if slide_text:
|
||||
slides.append(f"--- Slide {i} ---\n" + "\n".join(slide_text))
|
||||
return _truncate("\n\n".join(slides), _MAX_TEXT_LENGTH)
|
||||
except Exception as e:
|
||||
logger.error("Failed to extract PPTX {}: {}", path, e)
|
||||
return f"[error: failed to extract PPTX: {e!s}]"
|
||||
|
||||
|
||||
def _collect_pptx_shape_text(shape, out: list[str]) -> None:
|
||||
"""Collect text from a PPTX shape, recursing into groups and tables.
|
||||
|
||||
Groups have ``has_text_frame=False`` and must be walked via ``.shapes``;
|
||||
tables are GraphicFrame objects whose cell text lives under ``.table``.
|
||||
"""
|
||||
sub_shapes = getattr(shape, "shapes", None)
|
||||
if sub_shapes is not None:
|
||||
for sub in sub_shapes:
|
||||
_collect_pptx_shape_text(sub, out)
|
||||
return
|
||||
|
||||
if getattr(shape, "has_table", False):
|
||||
for row in shape.table.rows:
|
||||
cells = [cell.text.strip() for cell in row.cells]
|
||||
line = "\t".join(cell for cell in cells if cell)
|
||||
if line:
|
||||
out.append(line)
|
||||
return
|
||||
|
||||
text = getattr(shape, "text", "")
|
||||
if text:
|
||||
out.append(text)
|
||||
|
||||
|
||||
def _extract_text_file(path: Path) -> str:
|
||||
"""Extract text from a plain text file."""
|
||||
try:
|
||||
# Try UTF-8 first, then latin-1 fallback
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = path.read_text(encoding="latin-1")
|
||||
return _truncate(content, _MAX_TEXT_LENGTH)
|
||||
except Exception as e:
|
||||
logger.error("Failed to read text file {}: {}", path, e)
|
||||
return f"[error: failed to read file: {e!s}]"
|
||||
|
||||
|
||||
def _truncate(text: str, max_length: int) -> str:
|
||||
"""Truncate text with a suffix indicating truncation."""
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return text[:max_length] + f"... (truncated, {len(text)} chars total)"
|
||||
|
||||
|
||||
def _is_text_extension(ext: str) -> bool:
|
||||
"""Check if extension is a text format."""
|
||||
return ext in {
|
||||
".txt",
|
||||
".md",
|
||||
".csv",
|
||||
".json",
|
||||
".xml",
|
||||
".html",
|
||||
".htm",
|
||||
".log",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".ini",
|
||||
".cfg",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level helper: split media into images + extracted document text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
|
||||
|
||||
|
||||
def extract_documents(
|
||||
text: str,
|
||||
media_paths: list[str],
|
||||
*,
|
||||
max_file_size: int = _MAX_EXTRACT_FILE_SIZE,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Separate images from documents in *media_paths*.
|
||||
|
||||
Documents (PDF, DOCX, XLSX, PPTX, plain-text, …) have their text
|
||||
extracted and appended to *text*. Only image paths are kept in the
|
||||
returned list so that downstream layers only need to handle vision
|
||||
blocks.
|
||||
|
||||
Files larger than *max_file_size* bytes are skipped with a warning
|
||||
to avoid unbounded memory / CPU usage.
|
||||
"""
|
||||
image_paths: list[str] = []
|
||||
doc_texts: list[str] = []
|
||||
|
||||
for path_str in media_paths:
|
||||
p = Path(path_str)
|
||||
if not p.is_file():
|
||||
continue
|
||||
|
||||
try:
|
||||
size = p.stat().st_size
|
||||
except OSError:
|
||||
continue
|
||||
if size > max_file_size:
|
||||
logger.warning(
|
||||
"Skipping oversized file for extraction: {} ({:.1f} MB > {} MB limit)",
|
||||
p.name, size / (1024 * 1024), max_file_size // (1024 * 1024),
|
||||
)
|
||||
continue
|
||||
|
||||
with open(p, "rb") as f:
|
||||
header = f.read(16)
|
||||
mime = detect_image_mime(header) or mimetypes.guess_type(path_str)[0]
|
||||
if mime and mime.startswith("image/"):
|
||||
image_paths.append(path_str)
|
||||
else:
|
||||
extracted = extract_text(p)
|
||||
if extracted and not extracted.startswith("[error:"):
|
||||
doc_texts.append(f"[File: {p.name}]\n{extracted}")
|
||||
|
||||
if doc_texts:
|
||||
text = text + "\n\n" + "\n\n".join(doc_texts)
|
||||
|
||||
return text, image_paths
|
||||
@@ -68,8 +68,14 @@ async def evaluate_response(
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
if not llm_response.has_tool_calls:
|
||||
logger.warning("evaluate_response: no tool call returned, defaulting to notify")
|
||||
if not llm_response.should_execute_tools:
|
||||
if llm_response.has_tool_calls:
|
||||
logger.warning(
|
||||
"evaluate_response: ignoring tool calls under finish_reason='{}', defaulting to notify",
|
||||
llm_response.finish_reason,
|
||||
)
|
||||
else:
|
||||
logger.warning("evaluate_response: no tool call returned, defaulting to notify")
|
||||
return True
|
||||
|
||||
args = llm_response.tool_calls[0].arguments
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from loguru import logger
|
||||
@@ -24,6 +25,23 @@ class CommitInfo:
|
||||
return f"{header}\n(no file changes)"
|
||||
|
||||
|
||||
@dataclass
|
||||
class LineAge:
|
||||
"""Age of a single line based on git blame."""
|
||||
|
||||
age_days: int # days since last modification
|
||||
|
||||
|
||||
def _compute_line_ages(annotated) -> list[LineAge]:
|
||||
"""Convert annotate results to per-line ages."""
|
||||
now = datetime.now(tz=timezone.utc).date()
|
||||
ages: list[LineAge] = []
|
||||
for (commit, _tree_entry), _line_bytes in annotated:
|
||||
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
|
||||
ages.append(LineAge(age_days=(now - dt).days))
|
||||
return ages
|
||||
|
||||
|
||||
class GitStore:
|
||||
"""Git-backed version control for memory files."""
|
||||
|
||||
@@ -46,14 +64,35 @@ class GitStore:
|
||||
if self.is_initialized():
|
||||
return False
|
||||
|
||||
if self._is_inside_git_repo():
|
||||
logger.warning(
|
||||
"Workspace {} is already inside a git repo; "
|
||||
"skipping nested repo initialization",
|
||||
self._workspace,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
from dulwich import porcelain
|
||||
|
||||
porcelain.init(str(self._workspace))
|
||||
|
||||
# Write .gitignore
|
||||
# Write .gitignore (merge with existing if present)
|
||||
gitignore = self._workspace / ".gitignore"
|
||||
gitignore.write_text(self._build_gitignore(), encoding="utf-8")
|
||||
dream_entries = self._build_gitignore()
|
||||
if gitignore.exists():
|
||||
existing = gitignore.read_text(encoding="utf-8")
|
||||
existing_lines = set(existing.splitlines())
|
||||
new_lines = [
|
||||
line
|
||||
for line in dream_entries.splitlines()
|
||||
if line not in existing_lines
|
||||
]
|
||||
if new_lines:
|
||||
merged = existing.rstrip("\n") + "\n" + "\n".join(new_lines) + "\n"
|
||||
gitignore.write_text(merged, encoding="utf-8")
|
||||
else:
|
||||
gitignore.write_text(dream_entries, encoding="utf-8")
|
||||
|
||||
# Ensure tracked files exist (touch them if missing) so the initial
|
||||
# commit has something to track.
|
||||
@@ -137,6 +176,22 @@ class GitStore:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _is_inside_git_repo(self) -> bool:
|
||||
"""Check if self._workspace is already inside a git repository.
|
||||
|
||||
Walks up from self._workspace to the filesystem root, returning True
|
||||
if any parent directory contains a .git entry.
|
||||
|
||||
Git worktrees and submodules can use a ``.git`` file instead of a
|
||||
directory, so we must treat either form as "already inside a repo".
|
||||
"""
|
||||
current = self._workspace.resolve()
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return True
|
||||
current = current.parent
|
||||
return False
|
||||
|
||||
def _build_gitignore(self) -> str:
|
||||
"""Generate .gitignore content from tracked files."""
|
||||
dirs: set[str] = set()
|
||||
@@ -191,6 +246,34 @@ class GitStore:
|
||||
logger.warning("Git log failed")
|
||||
return []
|
||||
|
||||
def line_ages(self, file_path: str) -> list[LineAge]:
|
||||
"""Compute the age of each line in a tracked file via git blame.
|
||||
|
||||
Returns one LineAge per line, in order.
|
||||
Returns an empty list if the repo is not initialized, the file is
|
||||
empty, or annotation fails.
|
||||
"""
|
||||
|
||||
if not self.is_initialized():
|
||||
return []
|
||||
|
||||
target = self._workspace / file_path
|
||||
if not target.exists() or target.stat().st_size == 0:
|
||||
return []
|
||||
|
||||
try:
|
||||
from dulwich import porcelain
|
||||
|
||||
annotated = porcelain.annotate(str(self._workspace), file_path)
|
||||
except Exception:
|
||||
logger.warning("Git line_ages annotate failed for {}", file_path)
|
||||
return []
|
||||
|
||||
if not annotated:
|
||||
return []
|
||||
|
||||
return _compute_line_ages(annotated)
|
||||
|
||||
def diff_commits(self, sha1: str, sha2: str) -> str:
|
||||
"""Show diff between two commits."""
|
||||
if not self.is_initialized():
|
||||
|
||||
@@ -400,6 +400,8 @@ def build_status_content(
|
||||
session_msg_count: int,
|
||||
context_tokens_estimate: int,
|
||||
search_usage_text: str | None = None,
|
||||
active_task_count: int = 0,
|
||||
max_completion_tokens: int = 8192,
|
||||
) -> str:
|
||||
"""Build a human-readable runtime status snapshot.
|
||||
|
||||
@@ -418,7 +420,9 @@ def build_status_content(
|
||||
last_out = last_usage.get("completion_tokens", 0)
|
||||
cached = last_usage.get("cached_tokens", 0)
|
||||
ctx_total = max(context_window_tokens, 0)
|
||||
ctx_pct = int((context_tokens_estimate / ctx_total) * 100) if ctx_total > 0 else 0
|
||||
# Budget mirrors Consolidator formula: ctx_window - max_completion - _SAFETY_BUFFER
|
||||
ctx_budget = max(ctx_total - int(max_completion_tokens) - 1024, 1)
|
||||
ctx_pct = min(int((context_tokens_estimate / ctx_budget) * 100), 999) if ctx_budget > 0 else 0
|
||||
ctx_used_str = f"{context_tokens_estimate // 1000}k" if context_tokens_estimate >= 1000 else str(context_tokens_estimate)
|
||||
ctx_total_str = f"{ctx_total // 1000}k" if ctx_total > 0 else "n/a"
|
||||
token_line = f"\U0001f4ca Tokens: {last_in} in / {last_out} out"
|
||||
@@ -428,9 +432,10 @@ def build_status_content(
|
||||
f"\U0001f408 nanobot v{version}",
|
||||
f"\U0001f9e0 Model: {model}",
|
||||
token_line,
|
||||
f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}%)",
|
||||
f"\U0001f4da Context: {ctx_used_str}/{ctx_total_str} ({ctx_pct}% of input budget)",
|
||||
f"\U0001f4ac Session: {session_msg_count} messages",
|
||||
f"\u23f1 Uptime: {uptime}",
|
||||
f"\u26a1 Tasks: {active_task_count} active",
|
||||
]
|
||||
if search_usage_text:
|
||||
lines.append(search_usage_text)
|
||||
|
||||
Reference in New Issue
Block a user