refactor: enforce BasedPyright strict type checking (#5158)

This commit is contained in:
chengyongru
2026-07-29 21:37:11 +08:00
committed by GitHub
parent e703481755
commit 757ad9c764
166 changed files with 4728 additions and 2621 deletions
+8 -5
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection
from datetime import datetime
from typing import TYPE_CHECKING, Callable, Coroutine
from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
from loguru import logger
@@ -65,7 +65,7 @@ class AutoCompact:
def check_expired(
self,
schedule_background: Callable[[Coroutine], None],
schedule_background: Callable[[Coroutine[Any, Any, None]], None],
resolve_runtime: Callable[[Session], LLMRuntime],
active_session_keys: Collection[str] = (),
) -> None:
@@ -103,8 +103,8 @@ class AutoCompact:
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
self._summaries[key] = (
meta["text"],
datetime.fromisoformat(meta["last_active"]),
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
except Exception:
logger.exception("Auto-compact: failed for {}", key)
@@ -126,5 +126,8 @@ class AutoCompact:
# Cold path: summary persisted in session metadata (process restarted).
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"]))
return session, self._format_summary(
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
return session, None
+11 -6
View File
@@ -4,7 +4,7 @@ import base64
import mimetypes
import platform
from pathlib import Path
from typing import Any, Mapping, Sequence
from typing import Any, Mapping, Sequence, cast
from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader
@@ -148,7 +148,12 @@ class ContextBuilder:
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value]
return [
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
]
if value is None:
return []
return [{"type": "text", "text": str(value)}]
@@ -157,7 +162,7 @@ class ContextBuilder:
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load project instructions plus the agent's global profile files."""
parts = []
parts: list[str] = []
project_root = workspace or self.workspace
sources = [
("AGENTS.md", project_root),
@@ -212,7 +217,7 @@ class ContextBuilder:
user_content = self.build_user_content(current_message, image_paths=media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(user_content, blocks)
messages = [
messages: list[dict[str, Any]] = [
{
"role": "system",
"content": self.build_system_prompt(
@@ -235,7 +240,7 @@ class ContextBuilder:
last["_meta"] = internal_meta
messages[-1] = last
return messages
current = {"role": current_role, "content": merged}
current: dict[str, Any] = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta}
messages.append(current)
@@ -250,7 +255,7 @@ class ContextBuilder:
if not image_paths:
return text
image_blocks = []
image_blocks: list[dict[str, Any]] = []
for path in image_paths:
p = Path(path)
if not p.is_file():
+22 -14
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from loguru import logger
@@ -23,6 +23,7 @@ from nanobot.utils.helpers import (
from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024
@@ -49,8 +50,9 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""
if not isinstance(tool_call, dict):
return False
fn = tool_call.get("function")
name = fn.get("name") if isinstance(fn, dict) else tool_call.get("name")
tool_call_data = cast(dict[str, Any], tool_call)
fn = tool_call_data.get("function")
name = cast(dict[str, Any], fn).get("name") if isinstance(fn, dict) else tool_call_data.get("name")
return isinstance(name, str) and bool(name)
@@ -58,7 +60,7 @@ def _tool_call_name_is_valid(tool_call: Any) -> bool:
class ContextGovernanceConfig:
provider: LLMProvider
model: str
tools: Any
tools: ToolRegistry
workspace: Path | None
session_key: str | None
max_tool_result_chars: int
@@ -199,7 +201,7 @@ class ContextGovernor:
if updated is not None:
updated.append(msg)
continue
kept = [tc for tc in calls if _tool_call_name_is_valid(tc)]
kept = [tc for tc in cast(list[Any], calls) if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls):
if updated is not None:
updated.append(msg)
@@ -238,9 +240,11 @@ class ContextGovernor:
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
declared.add(str(tc["id"]))
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
declared.add(str(tool_call["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
tid_str = str(tid) if tid else ""
@@ -266,13 +270,17 @@ class ContextGovernor:
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if isinstance(tc, dict) and tc.get("id"):
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
name = ""
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", "")
declared.append((idx, str(tc["id"]), name))
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
func = tool_call.get("function")
if isinstance(func, dict):
func_data = cast(dict[str, Any], func)
raw_name = func_data.get("name", "")
name = raw_name if isinstance(raw_name, str) else str(raw_name)
declared.append((idx, str(tool_call["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
+7 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from typing import Any, cast
from nanobot.agent.hook import (
AgentHook,
@@ -56,17 +56,21 @@ class FileEditActivityHook(AgentHook):
) -> None:
if self._on_progress is None or not isinstance(params, dict):
return
typed_params = cast(dict[str, Any], params)
trackers = prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=self._workspace,
params=params,
params=typed_params,
)
if not trackers:
return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
await self._emit([
build_file_edit_start_event(tracker, typed_params)
for tracker in trackers
])
async def after_execute_tool(
self,
+165 -81
View File
@@ -1,5 +1,7 @@
"""Agent loop: the core processing engine."""
# pyright: reportPrivateUsage=false
from __future__ import annotations
import asyncio
@@ -7,13 +9,13 @@ import dataclasses
import inspect
import os
import time
from collections.abc import Mapping
from collections.abc import Coroutine, Iterable, Mapping
from contextlib import AbstractContextManager, ExitStack, nullcontext, suppress
from dataclasses import dataclass, field
from enum import Enum, auto
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar
from typing import TYPE_CHECKING, Any, Awaitable, Callable, TypeVar, cast
from loguru import logger
@@ -94,10 +96,13 @@ if TYPE_CHECKING:
from nanobot.agent.tools.mcp import MCPConnection
from nanobot.config.schema import (
ChannelsConfig,
Config,
MCPServerConfig,
ProviderConfig,
ToolsConfig,
)
from nanobot.cron.service import CronService
from nanobot.triggers.local_store import LocalTriggerStore
_T = TypeVar("_T")
@@ -142,7 +147,7 @@ class TurnContext:
on_runtime_admitted: Callable[[LLMRuntime], Awaitable[None]] | None = None
on_retry_wait: Callable[[str], Awaitable[None]] | None = None
pending_queue: asyncio.Queue | None = None
pending_queue: asyncio.Queue[InboundMessage] | None = None
pending_summary: str | None = None
ephemeral: bool = False
@@ -156,6 +161,18 @@ class TurnContext:
visible_run_started_at: float | None = None
turn_latency_ms: int | None = None
def require_runtime(self) -> LLMRuntime:
"""Return the runtime established by the BUILD stage."""
if self.runtime is None:
raise RuntimeError("turn runtime is not initialized; BUILD must run before this stage")
return self.runtime
def require_session(self) -> Session:
"""Return the session established by the RESTORE stage."""
if self.session is None:
raise RuntimeError("turn session is not initialized; RESTORE must run before this stage")
return self.session
class AgentLoop:
"""
@@ -243,7 +260,7 @@ class AgentLoop:
cron_service: CronService | None = None,
restrict_to_workspace: bool = False,
session_manager: SessionManager | None = None,
mcp_servers: dict | None = None,
mcp_servers: dict[str, MCPServerConfig] | None = None,
channels_config: ChannelsConfig | None = None,
timezone: str | None = None,
session_ttl_minutes: int = 0,
@@ -266,7 +283,7 @@ class AgentLoop:
turn_delivery_factory: TurnDeliveryFactory | None = None,
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto",
local_trigger_store: Any | None = None,
local_trigger_store: LocalTriggerStore | None = None,
idle_compact_check_interval_seconds: int = 0,
):
from nanobot.config.schema import ToolsConfig
@@ -381,7 +398,7 @@ class AgentLoop:
# Per-session pending queues for mid-turn message injection.
# When a session has an active task, new messages for that session
# are routed here instead of creating a new task.
self._pending_queues: dict[str, asyncio.Queue] = {}
self._pending_queues: dict[str, asyncio.Queue[InboundMessage]] = {}
self._deferred_automation_turns: dict[str, list[InboundMessage]] = {}
self._cron_turns = CronTurnCoordinator(
publish_inbound=self.bus.publish_inbound,
@@ -430,7 +447,7 @@ class AgentLoop:
@classmethod
def from_config(
cls,
config: Any,
config: Config,
bus: MessageBus | None = None,
**extra: Any,
) -> AgentLoop:
@@ -657,12 +674,17 @@ class AgentLoop:
"""
if not turn_continuation.should_persist_user_message(msg.metadata):
return False
media_paths = [p for p in (msg.media or []) if isinstance(p, str) and p]
has_text = isinstance(msg.content, str) and msg.content.strip()
media_paths = [
path
for path in (msg.media or [])
if isinstance(cast(object, path), str) and path
]
content_value = cast(object, msg.content)
has_text = isinstance(content_value, str) and content_value.strip()
if has_text or media_paths or runtime_context_blocks:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
text = content_value if isinstance(content_value, str) else ""
text_override, automation_extra = automation_history_overrides(msg.metadata)
if text_override is not None:
text = text_override
@@ -810,7 +832,7 @@ class AgentLoop:
async def _run_agent_loop(
self,
initial_messages: list[dict],
initial_messages: list[dict[str, Any]],
on_progress: Callable[..., Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
@@ -824,7 +846,7 @@ class AgentLoop:
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
original_user_text: str | None = None,
pending_queue: asyncio.Queue | None = None,
pending_queue: asyncio.Queue[InboundMessage] | None = None,
ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
@@ -832,7 +854,7 @@ class AgentLoop:
turn_scopes: list[AbstractContextManager[Any]] | None = None,
tools: ToolRegistry | None = None,
request_context: RequestContext | None = None,
) -> tuple[str | None, list[str], list[dict], str, bool]:
) -> tuple[str | None, list[str], list[dict[str, Any]], str, bool]:
"""Run the agent iteration loop.
*on_stream*: called with each content delta during streaming.
@@ -875,7 +897,12 @@ class AgentLoop:
image_paths=image_paths,
)
row: dict[str, Any] = {"role": "user", "content": user_content}
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {}
metadata_value = cast(object, pending_msg.metadata)
metadata = (
pending_msg.metadata
if isinstance(metadata_value, dict)
else {}
)
if pending_msg.channel != "system":
scope = self.workspace_scopes.for_turn(
channel=pending_msg.channel,
@@ -899,19 +926,24 @@ class AgentLoop:
pending_request,
effective_tools,
)
row["content"], marker = append_runtime_context(user_content, blocks)
if marker is not None:
row["_meta"] = {RUNTIME_CONTEXT_MESSAGE_META: marker}
row["content"], runtime_marker = append_runtime_context(
user_content,
blocks,
)
if runtime_marker is not None:
row["_meta"] = {
RUNTIME_CONTEXT_MESSAGE_META: runtime_marker,
}
if (
pending_msg.sender_id == "subagent"
and metadata.get("injected_event") == "subagent_result"
):
marker: dict[str, Any] = {"kind": "subagent_result"}
subagent_marker: dict[str, Any] = {"kind": "subagent_result"}
task_id = metadata.get("subagent_task_id")
if isinstance(task_id, str) and task_id:
marker["subagent_task_id"] = task_id
subagent_marker["subagent_task_id"] = task_id
row["subagent_task_id"] = task_id
row[HIDDEN_HISTORY_META] = marker
row[HIDDEN_HISTORY_META] = subagent_marker
row["injected_event"] = "subagent_result"
return row
@@ -1178,7 +1210,7 @@ class AgentLoop:
gate = self._concurrency_gate or nullcontext()
delivery = self.turn_delivery_factory.unrouted(msg, session_key)
pending: asyncio.Queue | None = None
pending: asyncio.Queue[InboundMessage] | None = None
try:
async with lock, gate:
# Only the task that owns the session lock may publish the
@@ -1304,7 +1336,7 @@ class AgentLoop:
if errors:
raise BaseExceptionGroup("failed to close agent resources", errors)
def _schedule_background(self, coro) -> None:
def _schedule_background(self, coro: Coroutine[Any, Any, Any]) -> None:
"""Schedule a coroutine as a tracked background task (drained on shutdown)."""
task = asyncio.create_task(coro)
self._background_tasks.add(task)
@@ -1322,7 +1354,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,
pending_queue: asyncio.Queue | None = None,
pending_queue: asyncio.Queue[InboundMessage] | None = None,
ephemeral: bool = False,
run_extra_hooks_for_ephemeral: bool = False,
hooks: list[AgentHook] | None = None,
@@ -1518,27 +1550,33 @@ class AgentLoop:
# ensure it exists in case this handler is invoked independently.
if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key)
session = ctx.session
self._remember_unified_session_route(
ctx.session,
session,
msg,
is_user_turn=ctx.original_user_text is not None,
)
await ctx.delivery.started()
if ctx.kind is TurnKind.USER:
self.workspace_scopes.persist_message_scope(ctx.session, msg)
self.workspace_scopes.persist_message_scope(session, msg)
if self._restore_runtime_checkpoint(ctx.session):
self.sessions.save(ctx.session)
if self._restore_pending_user_turn(ctx.session):
self.sessions.save(ctx.session)
if self._restore_runtime_checkpoint(session):
self.sessions.save(session)
if self._restore_pending_user_turn(session):
self.sessions.save(session)
async def _compact_session(self, ctx: TurnContext) -> None:
ctx.session, pending = self.auto_compact.prepare_session(ctx.session, ctx.session_key)
session = ctx.require_session()
ctx.session, pending = self.auto_compact.prepare_session(
session,
ctx.session_key,
)
ctx.pending_summary = pending
async def _dispatch_command(self, ctx: TurnContext) -> bool:
if ctx.kind is TurnKind.SYSTEM:
return False
session = ctx.require_session()
raw = ctx.msg.content.strip()
_, automation_metadata = automation_history_overrides(ctx.msg.metadata)
is_user_turn = (
@@ -1549,7 +1587,7 @@ class AgentLoop:
)
cmd_ctx = CommandContext(
msg=ctx.msg,
session=ctx.session,
session=session,
key=ctx.session_key,
raw=raw,
loop=self,
@@ -1567,13 +1605,13 @@ class AgentLoop:
# intentionally clears the session.
if cmd_ctx.raw.lower() != "/new":
ctx.input_persisted_early = self._persist_user_message_early(
ctx.msg, ctx.session, _command=True
ctx.msg, session, _command=True
)
ctx.session.add_message(
session.add_message(
"assistant", result.content, _command=True
)
self._clear_pending_user_turn(ctx.session)
self.sessions.save(ctx.session)
self._clear_pending_user_turn(session)
self.sessions.save(session)
if not ctx.ephemeral:
await self.runtime_event_publisher.session_turn_persisted(
ctx.msg,
@@ -1585,9 +1623,10 @@ class AgentLoop:
return False
async def _build_turn(self, ctx: TurnContext) -> None:
session = ctx.require_session()
runtime = ctx.runtime
if runtime is None:
runtime = self.runtime_for_session(ctx.session)
runtime = self.runtime_for_session(session)
ctx.runtime = runtime
if ctx.session_key.startswith("dream:"):
logger.info(
@@ -1602,7 +1641,7 @@ class AgentLoop:
)
if not ctx.ephemeral:
await self.consolidator.maybe_consolidate_by_tokens(
ctx.session,
session,
runtime=runtime,
replay_max_messages=replay_max_messages,
)
@@ -1617,18 +1656,18 @@ class AgentLoop:
"max_tokens": self._replay_token_budget(runtime),
"extend_to_user": is_subagent,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
ctx.history = session.get_history(**_hist_kwargs)
if is_subagent:
# Keep the durable internal delivery as an assistant record, but
# present this completion to the model as fresh follow-up input.
# Providers without assistant-prefill support drop trailing
# assistant messages, so using the persisted record as the current
# prompt would hide an independently dispatched subagent result.
if self._persist_subagent_followup(ctx.session, ctx.msg):
if self._persist_subagent_followup(session, ctx.msg):
logger.debug("Subagent result persisted for session {}", ctx.session_key)
self.sessions.save(ctx.session)
self.sessions.save(session)
ctx.input_persisted_early = True
ctx.delivery.record_runtime(ctx.runtime)
ctx.delivery.record_runtime(runtime)
ctx.request_context = self._request_context_for_turn(ctx)
if ctx.kind is TurnKind.USER:
@@ -1637,7 +1676,7 @@ class AgentLoop:
if ctx.kind is TurnKind.USER:
ctx.input_persisted_early = self._persist_user_message_early(
ctx.msg,
ctx.session,
session,
runtime_context_blocks=ctx.runtime_context_blocks,
)
@@ -1647,12 +1686,13 @@ class AgentLoop:
ctx.on_retry_wait = ctx.delivery.retry_wait_callback()
async def _run_turn(self, ctx: TurnContext) -> None:
runtime = ctx.require_runtime()
if ctx.visible_run_started_at is None:
ctx.visible_run_started_at = time.time()
await ctx.delivery.running(started_at=ctx.visible_run_started_at)
result = await self._run_agent_loop(
ctx.initial_messages,
runtime=ctx.runtime,
runtime=runtime,
on_progress=ctx.on_progress,
on_stream=ctx.on_stream,
on_stream_end=ctx.on_stream_end,
@@ -1682,6 +1722,8 @@ class AgentLoop:
await turn_continuation.maybe_continue_turn(ctx)
async def _persist_turn(self, ctx: TurnContext) -> None:
runtime = ctx.require_runtime()
session = ctx.require_session()
turn_continuation.prepare_save_boundary(ctx)
if (
@@ -1702,26 +1744,26 @@ class AgentLoop:
)
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
self._save_turn(
ctx.session, ctx.all_messages, ctx.save_skip,
session, ctx.all_messages, ctx.save_skip,
turn_latency_ms=ctx.turn_latency_ms,
)
ctx.delivery.record_latency(ctx.turn_latency_ms)
if not ctx.ephemeral:
ctx.session.enforce_file_cap(
session.enforce_file_cap(
on_archive=partial(self.context.memory.raw_archive, session_key=ctx.session_key)
)
self._schedule_background(
self.consolidator.maybe_consolidate_by_tokens(
ctx.session,
runtime=ctx.runtime,
session,
runtime=runtime,
replay_max_messages=replay_max_messages_for_context(
ctx.runtime.context_window_tokens
runtime.context_window_tokens
),
)
)
self._clear_pending_user_turn(ctx.session)
self._clear_runtime_checkpoint(ctx.session)
self.sessions.save(ctx.session)
self._clear_pending_user_turn(session)
self._clear_runtime_checkpoint(session)
self.sessions.save(session)
if not ctx.ephemeral:
await self.runtime_event_publisher.session_turn_persisted(
ctx.msg,
@@ -1744,7 +1786,7 @@ class AgentLoop:
return
ctx.outbound = self._assemble_outbound(
ctx.msg,
ctx.final_content,
cast(str, ctx.final_content),
ctx.stop_reason,
ctx.had_injections,
ctx.streamed_content,
@@ -1755,39 +1797,47 @@ class AgentLoop:
def _sanitize_persisted_blocks(
self,
content: list[dict[str, Any]],
content: list[object],
*,
should_truncate_text: bool = False,
) -> list[dict[str, Any]]:
) -> list[object]:
"""Strip volatile multimodal payloads before writing session history."""
filtered: list[dict[str, Any]] = []
filtered: list[object] = []
for block in content:
if not isinstance(block, dict):
filtered.append(block)
continue
if block.get("type") == "image_url" and block.get("image_url", {}).get(
"url", ""
block_data = cast(dict[str, Any], block)
image_url = cast(dict[str, Any], block_data.get("image_url", {}))
if block_data.get("type") == "image_url" and str(
image_url.get("url", "")
).startswith("data:image/"):
path = (block.get("_meta") or {}).get("path", "")
filtered.append({"type": "text", "text": image_placeholder_text(path)})
internal_meta = cast(dict[str, Any], block_data.get("_meta") or {})
path = cast(str, internal_meta.get("path", ""))
filtered.append(
{"type": "text", "text": image_placeholder_text(path)}
)
continue
if block.get("type") == "text" and isinstance(block.get("text"), str):
text = block["text"]
if block_data.get("type") == "text" and isinstance(
block_data.get("text"),
str,
):
text = cast(str, block_data["text"])
if should_truncate_text and len(text) > self.max_tool_result_chars:
text = truncate_text_fn(text, self.max_tool_result_chars)
filtered.append({**block, "text": text})
filtered.append({**block_data, "text": text})
continue
filtered.append(block)
filtered.append(block_data)
return filtered
def _save_turn(
self,
session: Session,
messages: list[dict],
messages: list[dict[str, Any]],
skip: int,
*,
turn_latency_ms: int | None = None,
@@ -1799,8 +1849,10 @@ class AgentLoop:
str(tc["id"])
for m in session.messages
if m.get("role") == "assistant"
for tc in m.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
for tc_value in cast(Iterable[object], m.get("tool_calls") or [])
if isinstance(tc_value, dict)
for tc in (cast(dict[str, Any], tc_value),)
if tc.get("id")
}
fulfilled_tool_call_ids = {
str(m["tool_call_id"])
@@ -1810,9 +1862,11 @@ class AgentLoop:
last_assistant_idx: int | None = None
for m in messages[skip:]:
entry = dict(m)
internal_meta = entry.pop("_meta", None)
internal_meta = cast(object, entry.pop("_meta", None))
runtime_context_meta = (
internal_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
cast(dict[str, Any], internal_meta).get(
RUNTIME_CONTEXT_MESSAGE_META
)
if isinstance(internal_meta, dict)
else None
)
@@ -1838,7 +1892,10 @@ class AgentLoop:
if isinstance(content, str) and len(content) > self.max_tool_result_chars:
entry["content"] = truncate_text_fn(content, self.max_tool_result_chars)
elif isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content, should_truncate_text=True)
filtered = self._sanitize_persisted_blocks(
cast(list[object], content),
should_truncate_text=True,
)
if not filtered:
# Preserve the tool_call/result pair after block filtering.
filtered = [
@@ -1847,7 +1904,9 @@ class AgentLoop:
entry["content"] = filtered
elif role == "user":
if isinstance(content, list):
filtered = self._sanitize_persisted_blocks(content)
filtered = self._sanitize_persisted_blocks(
cast(list[object], content),
)
if not filtered:
continue
entry["content"] = filtered
@@ -1859,8 +1918,13 @@ class AgentLoop:
last_assistant_idx = len(session.messages) - 1
declared_tool_call_ids.update(
str(tc["id"])
for tc in entry.get("tool_calls") or []
if isinstance(tc, dict) and tc.get("id")
for tc_value in cast(
Iterable[object],
entry.get("tool_calls") or [],
)
if isinstance(tc_value, dict)
for tc in (cast(dict[str, Any], tc_value),)
if tc.get("id")
)
if turn_latency_ms is not None and last_assistant_idx is not None:
session.messages[last_assistant_idx]["latency_ms"] = int(turn_latency_ms)
@@ -1875,7 +1939,12 @@ class AgentLoop:
"""
if not msg.content:
return False
task_id = msg.metadata.get("subagent_task_id") if isinstance(msg.metadata, dict) else None
metadata_value = cast(object, msg.metadata)
task_id = (
msg.metadata.get("subagent_task_id")
if isinstance(metadata_value, 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
@@ -1921,29 +1990,44 @@ class AgentLoop:
"""Materialize an unfinished turn into session history before a new request."""
from datetime import datetime
checkpoint = session.metadata.get(self._RUNTIME_CHECKPOINT_KEY)
checkpoint = cast(
object,
session.metadata.get(self._RUNTIME_CHECKPOINT_KEY),
)
if not isinstance(checkpoint, dict):
return False
checkpoint_data = cast(dict[str, Any], checkpoint)
assistant_message = checkpoint.get("assistant_message")
completed_tool_results = checkpoint.get("completed_tool_results") or []
pending_tool_calls = checkpoint.get("pending_tool_calls") or []
assistant_message = cast(object, checkpoint_data.get("assistant_message"))
completed_tool_results = cast(
Iterable[object],
checkpoint_data.get("completed_tool_results") or [],
)
pending_tool_calls = cast(
Iterable[object],
checkpoint_data.get("pending_tool_calls") or [],
)
restored_messages: list[dict[str, Any]] = []
if isinstance(assistant_message, dict):
restored = dict(assistant_message)
restored = dict(cast(dict[str, Any], assistant_message))
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for message in completed_tool_results:
if isinstance(message, dict):
restored = dict(message)
restored = dict(cast(dict[str, Any], message))
restored.setdefault("timestamp", datetime.now().isoformat())
restored_messages.append(restored)
for tool_call in pending_tool_calls:
if not isinstance(tool_call, dict):
continue
tool_id = tool_call.get("id")
name = ((tool_call.get("function") or {}).get("name")) or "tool"
tool_call_data = cast(dict[str, Any], tool_call)
tool_id = tool_call_data.get("id")
function_data = cast(
dict[str, Any],
tool_call_data.get("function") or {},
)
name = function_data.get("name") or "tool"
restored_messages.append(
{
"role": "tool",
+40 -24
View File
@@ -1,5 +1,10 @@
"""Memory system: pure file I/O store and lightweight Consolidator."""
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
# runtime; static analyzers cannot observe that it clears ``parameters`` from
# ``__abstractmethods__`` before these classes are instantiated.
# pyright: reportAbstractUsage=false, reportPrivateUsage=false
from __future__ import annotations
import asyncio
@@ -11,7 +16,7 @@ import weakref
from contextlib import suppress
from datetime import datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator
from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from loguru import logger
@@ -38,6 +43,7 @@ from nanobot.utils.workspace_prompts import (
)
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.utils.llm_runtime import LLMRuntime
# ---------------------------------------------------------------------------
@@ -58,7 +64,7 @@ class DreamRunProgress:
**_kwargs: Any,
) -> None:
if any(
isinstance(event, dict) and event.get("phase") == "error"
isinstance(cast(object, event), dict) and event.get("phase") == "error"
for event in tool_events or ()
):
self.had_tool_errors = True
@@ -474,11 +480,11 @@ class MemoryStore:
line = line.strip()
if line:
try:
parsed = json.loads(line)
parsed: object = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(parsed, dict):
entries.append(parsed)
entries.append(cast(dict[str, Any], parsed))
return entries
@@ -496,8 +502,8 @@ class MemoryStore:
lines = [line for line in data.split("\n") if line.strip()]
if not lines:
return None
parsed = json.loads(lines[-1])
return parsed if isinstance(parsed, dict) else None
parsed: object = json.loads(lines[-1])
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return None
@@ -612,7 +618,7 @@ class MemoryStore:
("USER.md", self.user_file),
("memory/MEMORY.md", self.memory_file),
]
blocks = []
blocks: list[str] = []
for label, path in files:
try:
content = path.read_text(encoding="utf-8") if path.exists() else ""
@@ -633,7 +639,7 @@ class MemoryStore:
return ""
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
def build_dream_tools(self):
def build_dream_tools(self) -> ToolRegistry:
"""Build the restricted tool registry used by Dream runs."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.apply_patch import ApplyPatchTool
@@ -684,17 +690,15 @@ class MemoryStore:
) -> bool:
"""Return True only when a Dream turn completed without tool failures."""
metadata = getattr(resp, "metadata", None)
return (
not had_tool_errors
and isinstance(metadata, dict)
and metadata.get("_stop_reason") == "completed"
)
if had_tool_errors or not isinstance(metadata, dict):
return False
return cast(dict[str, Any], metadata).get("_stop_reason") == "completed"
# -- message formatting utility ------------------------------------------
@staticmethod
def _format_messages(messages: list[dict]) -> str:
lines = []
def _format_messages(messages: list[dict[str, Any]]) -> str:
lines: list[str] = []
for message in messages:
content = content_with_media_breadcrumbs(
message.get("role"),
@@ -703,16 +707,22 @@ class MemoryStore:
)
if not content:
continue
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else ""
tools_used = message.get("tools_used")
tools = (
f" [tools: {', '.join(cast(list[str], tools_used))}]"
if tools_used
else ""
)
timestamp = cast(str, message.get("timestamp", "?"))
role = cast(str, message["role"])
lines.append(
f"[{message.get('timestamp', '?')[:16]}] "
f"{message['role'].upper()}{tools}: {content}"
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
)
return "\n".join(lines)
def raw_archive(
self,
messages: list[dict],
messages: list[dict[str, Any]],
*,
max_chars: int | None = None,
session_key: str | None = None,
@@ -766,9 +776,9 @@ class MemoryStore:
Only current base64url-encoded Dream session keys are considered.
Non-dream session files are never touched.
"""
dream_files = []
dream_files: list[Path] = []
for path in sessions_dir.glob("*.jsonl"):
decoded_key = SessionManager._decode_storage_key(path.stem)
decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime)
@@ -943,7 +953,13 @@ class Consolidator:
channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary")
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None)
summary = (
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
)
probe_messages = self._build_messages(
history=history,
current_message="[token-probe]",
@@ -976,11 +992,11 @@ class Consolidator:
async def archive(
self,
messages: list[dict],
messages: list[dict[str, Any]],
*,
runtime: LLMRuntime,
session_key: str | None = None,
summary_messages: list[dict] | None = None,
summary_messages: list[dict[str, Any]] | None = None,
) -> str | None:
"""Summarize messages via LLM and append to history.jsonl.
+3 -4
View File
@@ -5,9 +5,8 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import replace
from pathlib import Path
from typing import Any
from nanobot.config.schema import ModelPresetConfig
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
@@ -22,7 +21,7 @@ def default_selection_signature(
return (model_preset, *signature[:2]) if signature else None
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]:
def configured_model_presets(config: Config) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()}
@@ -41,7 +40,7 @@ def load_model_preset_catalog(
def make_preset_snapshot_loader(
config: Any,
config: Config,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
) -> PresetSnapshotLoader:
if provider_snapshot_loader is not None:
+5 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import replace
from types import MappingProxyType
from typing import cast
from nanobot.agent import model_presets as preset_helpers
from nanobot.config.schema import Config, ModelPresetConfig
@@ -139,7 +140,7 @@ class ModelRuntimeResolver:
def select_model(self, model: str) -> LLMRuntime:
"""Change the default model without reconstructing downstream consumers."""
if not isinstance(model, str) or not model.strip():
if not isinstance(cast(object, model), str) or not model.strip():
raise ValueError("model must be a non-empty string")
self._runtime = replace(
self._runtime,
@@ -150,8 +151,9 @@ class ModelRuntimeResolver:
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
"""Change the default context limit for future admissions."""
if not isinstance(context_window_tokens, int) or isinstance(
context_window_tokens,
raw_context_window = cast(object, context_window_tokens)
if not isinstance(raw_context_window, int) or isinstance(
raw_context_window,
bool,
):
raise TypeError("context_window_tokens must be an integer")
+3 -3
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import inspect
import json
from typing import Any, Awaitable, Callable
from typing import Any, Awaitable, Callable, cast
from loguru import logger
@@ -124,7 +124,7 @@ class AgentProgressHook(AgentHook):
arguments = event.get("arguments")
if not isinstance(arguments, dict):
arguments = {}
payload = {
payload: dict[str, Any] = {
"version": 1,
"phase": phase,
"call_id": str(call_id),
@@ -169,7 +169,7 @@ class AgentProgressHook(AgentHook):
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
await invoke_on_progress(
self._on_progress,
tool_hint,
cast(str, tool_hint),
tool_hint=True,
tool_events=tool_events,
)
+66 -39
View File
@@ -5,10 +5,11 @@ from __future__ import annotations
import asyncio
import inspect
import os
from collections.abc import Awaitable, Callable, Iterable
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from typing import Any, cast
from loguru import logger
@@ -48,6 +49,10 @@ from nanobot.utils.runtime import (
)
GoalContinueMessage = str | Callable[[], str | None]
ProgressCallback = Callable[[str], Awaitable[None]]
RetryWaitCallback = Callable[[str], Awaitable[None]]
CheckpointCallback = Callable[[dict[str, Any]], Awaitable[None]]
InjectionCallback = Callable[..., Awaitable[Iterable[Any] | None]]
_DEFAULT_ERROR_MESSAGE = "Sorry, I encountered an error calling the AI model."
_ARREARAGE_ERROR_MESSAGE = (
@@ -90,11 +95,11 @@ class AgentRunSpec:
session_key: str | None = None
context_block_limit: int | None = None
provider_retry_mode: str = "standard"
progress_callback: Any | None = None
progress_callback: ProgressCallback | None = None
stream_progress_deltas: bool = True
retry_wait_callback: Any | None = None
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
retry_wait_callback: RetryWaitCallback | None = None
checkpoint_callback: CheckpointCallback | None = None
injection_callback: InjectionCallback | None = None
llm_timeout_s: float | None = None
goal_active_predicate: Callable[[], bool] | None = None
goal_continue_message: GoalContinueMessage | None = None
@@ -131,8 +136,10 @@ class AgentRunner:
def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list):
return [
item if isinstance(item, dict) else {"type": "text", "text": str(item)}
for item in value
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
]
if value is None:
return []
@@ -158,25 +165,37 @@ class AgentRunner:
merged = dict(messages[-1])
left_meta = merged.get("_meta")
right_meta = injection.get("_meta")
left_meta_dict = cast(dict[str, Any], left_meta) if isinstance(left_meta, dict) else None
right_meta_dict = (
cast(dict[str, Any], right_meta) if isinstance(right_meta, dict) else None
)
left_marker = (
left_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(left_meta, dict)
left_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if left_meta_dict is not None
else None
)
right_marker = (
right_meta.get(RUNTIME_CONTEXT_MESSAGE_META)
if isinstance(right_meta, dict)
right_meta_dict.get(RUNTIME_CONTEXT_MESSAGE_META)
if right_meta_dict is not None
else None
)
left_marker_dict = (
cast(dict[str, Any], left_marker) if isinstance(left_marker, dict) else None
)
right_marker_dict = (
cast(dict[str, Any], right_marker) if isinstance(right_marker, dict) else None
)
empty_sources: list[str] = []
empty_blocks: list[dict[str, Any]] = []
detached_left = (
detach_runtime_context(merged.get("content"), left_marker)
if isinstance(left_marker, dict)
else (merged.get("content"), [], [])
detach_runtime_context(merged.get("content"), left_marker_dict)
if left_marker_dict is not None
else (merged.get("content"), empty_sources, empty_blocks)
)
detached_right = (
detach_runtime_context(injection.get("content"), right_marker)
if isinstance(right_marker, dict)
else (injection.get("content"), [], [])
detach_runtime_context(injection.get("content"), right_marker_dict)
if right_marker_dict is not None
else (injection.get("content"), empty_sources, empty_blocks)
)
if detached_left is not None and detached_right is not None:
left_content, left_sources, left_blocks = detached_left
@@ -189,9 +208,9 @@ class AgentRunner:
[*left_sources, *right_sources],
context_blocks,
)
internal_meta = dict(left_meta) if isinstance(left_meta, dict) else {}
if isinstance(right_meta, dict):
for key, value in right_meta.items():
internal_meta = dict(left_meta_dict) if left_meta_dict is not None else {}
if right_meta_dict is not None:
for key, value in right_meta_dict.items():
internal_meta.setdefault(key, value)
internal_meta[RUNTIME_CONTEXT_MESSAGE_META] = marker
merged["_meta"] = internal_meta
@@ -302,11 +321,11 @@ class AgentRunner:
for item in items:
if item is None:
continue
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
if self._has_injection_content(item.get("content")):
injected_messages.append(item)
continue
if isinstance(item, dict):
message_item = cast(dict[str, Any], item)
if message_item.get("role") == "user" and "content" in message_item:
if self._has_injection_content(message_item.get("content")):
injected_messages.append(message_item)
continue
content = getattr(item, "content") if hasattr(item, "content") else str(item)
if self._has_injection_content(content):
@@ -327,7 +346,7 @@ class AgentRunner:
if isinstance(content, str):
return bool(content.strip())
if isinstance(content, list):
return bool(content)
return bool(cast(list[Any], content))
return True
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
@@ -592,7 +611,7 @@ class AgentRunner:
if response.finish_reason == "length" and not is_blank_text(clean):
if len(length_recovery_parts) < _MAX_LENGTH_RECOVERIES:
length_recovery_parts.append(
_restore_outer_whitespace(clean, original_content)
_restore_outer_whitespace(clean or "", original_content)
)
logger.info(
"Output truncated on turn {} for {} ({}/{}); continuing",
@@ -609,7 +628,7 @@ class AgentRunner:
reasoning_content=response.reasoning_content,
thinking_blocks=response.thinking_blocks,
))
messages.append(build_length_recovery_message(clean))
messages.append(build_length_recovery_message(clean or ""))
await hook.after_iteration(context)
continue
@@ -626,7 +645,7 @@ class AgentRunner:
):
await hook.on_stream(
context,
_restore_outer_whitespace(clean, original_content),
_restore_outer_whitespace(clean or "", original_content),
)
context.streamed_content = True
@@ -717,7 +736,7 @@ class AgentRunner:
if length_recovery_parts:
final_content = (
"".join(length_recovery_parts)
+ _restore_outer_whitespace(clean, original_content)
+ _restore_outer_whitespace(clean or "", original_content)
).strip()
else:
final_content = clean
@@ -798,7 +817,7 @@ class AgentRunner:
context: AgentHookContext,
*,
malformed_retry: bool = False,
):
) -> LLMResponse:
timeout_s: float | None = spec.llm_timeout_s
if timeout_s is None:
# Default to a finite timeout to avoid per-session lock starvation when an LLM
@@ -809,7 +828,7 @@ class AgentRunner:
timeout_s = float(raw)
except (TypeError, ValueError):
timeout_s = 300.0
if timeout_s is not None and timeout_s <= 0:
if timeout_s <= 0:
timeout_s = None
kwargs = self._build_request_kwargs(
@@ -818,10 +837,11 @@ class AgentRunner:
tools=spec.tools.get_definitions(),
)
wants_streaming = hook.wants_streaming()
progress_callback = spec.progress_callback
wants_progress_streaming = (
not wants_streaming
and spec.stream_progress_deltas
and spec.progress_callback is not None
and progress_callback is not None
and getattr(spec.runtime.provider, "supports_progress_deltas", False) is True
)
@@ -894,7 +914,9 @@ class AgentRunner:
await hook.emit_reasoning_end()
progress_state["reasoning_open"] = False
context.streamed_content = True
await spec.progress_callback(incremental)
callback = progress_callback
if callback is not None:
await callback(incremental)
coro = spec.runtime.provider.chat_stream_with_retry(
**kwargs,
@@ -1038,7 +1060,7 @@ class AgentRunner:
self,
spec: AgentRunSpec,
messages: list[dict[str, Any]],
):
) -> LLMResponse:
retry_messages = self._finalization_retry_messages(messages)
return await self._request_no_tools(spec, retry_messages)
@@ -1224,7 +1246,7 @@ class AgentRunner:
))
tool_results.extend(batch_results)
else:
batch_results = []
batch_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for tool_call in batch:
result = await self._run_tool(
spec,
@@ -1273,12 +1295,17 @@ class AgentRunner:
if spec.fail_on_tool_error:
return lookup_error + hint, event, RuntimeError(lookup_error)
return lookup_error + hint, event, None
prepare_call = getattr(spec.tools, "prepare_call", None)
prepare_call = cast(
Callable[[str, Any], object] | None,
getattr(spec.tools, "prepare_call", None),
)
tool, params, prep_error = None, tool_call.arguments, None
if callable(prepare_call):
prepared = prepare_call(tool_call.name, tool_call.arguments)
if isinstance(prepared, tuple) and len(prepared) == 3:
tool, params, prep_error = prepared
if isinstance(prepared, tuple):
prepared_tuple = cast(tuple[object, ...], prepared)
if len(prepared_tuple) == 3:
tool, params, prep_error = cast(tuple[Any, Any, str | None], prepared_tuple)
if prep_error:
event = {
"name": tool_call.name,
@@ -1490,7 +1517,7 @@ class AgentRunner:
batches: list[list[ToolCallRequest]] = []
current: list[ToolCallRequest] = []
for tool_call in tool_calls:
get_tool = getattr(spec.tools, "get", None)
get_tool = cast(Callable[[str], Any] | None, getattr(spec.tools, "get", None))
tool = get_tool(tool_call.name) if callable(get_tool) else None
can_batch = bool(tool and tool.concurrency_safe)
if can_batch:
+23 -20
View File
@@ -5,6 +5,7 @@ import os
import re
import shutil
from pathlib import Path
from typing import Any, cast
import yaml
@@ -144,7 +145,7 @@ class SkillsLoader:
skill_name = entry["name"]
meta = self._get_skill_meta(skill_name)
available = self._check_requirements(meta)
desc = self._get_skill_description(skill_name)
desc = self.get_skill_description(skill_name)
suffix = ""
if not available:
missing = self._get_missing_requirements(meta)
@@ -155,18 +156,18 @@ class SkillsLoader:
return "\n\n".join(sections)
@staticmethod
def _requirement_lists(skill_meta: dict) -> tuple[list[str], list[str]]:
def _requirement_lists(skill_meta: dict[str, Any]) -> tuple[list[str], list[str]]:
"""Return (bins, env) lists from skill metadata, tolerating null/wrong shapes."""
requires = skill_meta.get("requires") or {}
if not isinstance(requires, dict):
requires = cast(dict[str, Any], skill_meta.get("requires") or {})
if not isinstance(skill_meta.get("requires") or {}, dict):
return [], []
bins_raw = requires.get("bins") or []
env_raw = requires.get("env") or []
bins = [str(v) for v in bins_raw if isinstance(v, str) and v.strip()] if isinstance(bins_raw, list) else []
env = [str(v) for v in env_raw if isinstance(v, str) and v.strip()] if isinstance(env_raw, list) else []
bins_raw: object = requires.get("bins") or []
env_raw: object = requires.get("env") or []
bins = [value for value in cast(list[object], bins_raw) if isinstance(value, str) and value.strip()] if isinstance(bins_raw, list) else []
env = [value for value in cast(list[object], env_raw) if isinstance(value, str) and value.strip()] if isinstance(env_raw, list) else []
return bins, env
def _get_missing_requirements(self, skill_meta: dict) -> str:
def _get_missing_requirements(self, skill_meta: dict[str, Any]) -> str:
"""Get a description of missing requirements."""
required_bins, required_env_vars = self._requirement_lists(skill_meta)
return ", ".join(
@@ -190,11 +191,12 @@ class SkillsLoader:
"missing_env": [value for value in env if not os.environ.get(value)],
}
def _get_skill_description(self, name: str) -> str:
def get_skill_description(self, name: str) -> str:
"""Get the description of a skill from its frontmatter."""
meta = self.get_skill_metadata(name)
if meta and meta.get("description"):
return meta["description"]
description = meta.get("description") if meta else None
if isinstance(description, str) and description:
return description
return name # Fallback to skill name
def _strip_frontmatter(self, content: str) -> str:
@@ -206,13 +208,13 @@ class SkillsLoader:
return content[match.end():].strip()
return content
def _parse_nanobot_metadata(self, raw: object) -> dict:
def _parse_nanobot_metadata(self, raw: object) -> dict[str, Any]:
"""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
data = cast(dict[str, Any], raw)
elif isinstance(raw, str):
try:
data = json.loads(raw)
@@ -222,17 +224,18 @@ class SkillsLoader:
return {}
if not isinstance(data, dict):
return {}
payload = data.get("nanobot", data.get("openclaw", {}))
return payload if isinstance(payload, dict) else {}
data_object = cast(dict[str, Any], data)
payload = data_object.get("nanobot", data_object.get("openclaw", {}))
return cast(dict[str, Any], payload) if isinstance(payload, dict) else {}
def _check_requirements(self, skill_meta: dict) -> bool:
def _check_requirements(self, skill_meta: dict[str, Any]) -> bool:
"""Check if skill requirements are met (bins, env vars)."""
required_bins, required_env_vars = self._requirement_lists(skill_meta)
return all(shutil.which(cmd) for cmd in required_bins) and all(
os.environ.get(var) for var in required_env_vars
)
def _get_skill_meta(self, name: str) -> dict:
def _get_skill_meta(self, name: str) -> dict[str, Any]:
"""Get nanobot metadata for a skill (cached in frontmatter)."""
raw_meta = self.get_skill_metadata(name) or {}
return self._parse_nanobot_metadata(raw_meta.get("metadata"))
@@ -249,7 +252,7 @@ class SkillsLoader:
)
]
def get_skill_metadata(self, name: str) -> dict | None:
def get_skill_metadata(self, name: str) -> dict[str, object] | None:
"""
Get metadata from a skill's frontmatter.
@@ -274,6 +277,6 @@ class SkillsLoader:
# 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():
for key, value in cast(dict[object, object], parsed).items():
metadata[str(key)] = value
return metadata
+21 -11
View File
@@ -7,12 +7,12 @@ import uuid
import warnings
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from typing import Any, Callable, TypedDict
from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.runner import AgentRunner, AgentRunResult, AgentRunSpec
from nanobot.agent.tools.base import ToolResult
from nanobot.agent.tools.context import (
RequestContext,
@@ -38,6 +38,12 @@ from nanobot.utils.llm_runtime import LLMRuntime
from nanobot.utils.prompt_templates import render_template
class _SubagentOrigin(TypedDict):
channel: str
chat_id: str
session_key: str | None
@dataclass(slots=True)
class SubagentStatus:
"""Real-time status of a running subagent."""
@@ -48,8 +54,8 @@ class SubagentStatus:
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
tool_events: list[dict[str, str]] = field(default_factory=list)
usage: dict[str, int] = field(default_factory=dict)
stop_reason: str | None = None
error: str | None = None
@@ -237,7 +243,11 @@ class SubagentManager:
runtime = runtime.with_generation_overrides(temperature=temperature)
task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {"channel": origin_channel, "chat_id": origin_chat_id, "session_key": session_key}
origin: _SubagentOrigin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
}
status = SubagentStatus(
task_id=task_id,
@@ -263,7 +273,7 @@ class SubagentManager:
if session_key:
self._session_tasks.setdefault(session_key, set()).add(task_id)
def _cleanup(_: asyncio.Task) -> None:
def _cleanup(_: asyncio.Task[str]) -> 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)):
@@ -296,7 +306,7 @@ class SubagentManager:
runtime = runtime.with_generation_overrides(temperature=temperature)
task_id = str(uuid.uuid4())[:8]
display_label = label or task[:30] + ("..." if len(task) > 30 else "")
origin = {
origin: _SubagentOrigin = {
"channel": origin_channel,
"chat_id": origin_chat_id,
"session_key": session_key,
@@ -343,7 +353,7 @@ class SubagentManager:
task_id: str,
task: str,
label: str,
origin: dict[str, str],
origin: _SubagentOrigin,
status: SubagentStatus,
runtime: LLMRuntime,
origin_message_id: str | None = None,
@@ -354,7 +364,7 @@ class SubagentManager:
"""Execute the subagent task and announce the result."""
logger.info("Subagent [{}] starting task: {}", task_id, label)
async def _on_checkpoint(payload: dict) -> None:
async def _on_checkpoint(payload: dict[str, Any]) -> None:
status.phase = payload.get("phase", status.phase)
status.iteration = payload.get("iteration", status.iteration)
@@ -456,7 +466,7 @@ class SubagentManager:
label: str,
task: str,
result: str,
origin: dict[str, str],
origin: _SubagentOrigin,
status: str,
origin_message_id: str | None = None,
) -> None:
@@ -496,7 +506,7 @@ class SubagentManager:
logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id'])
@staticmethod
def _format_partial_progress(result) -> str:
def _format_partial_progress(result: AgentRunResult) -> str:
completed = [e for e in result.tool_events if e["status"] == "ok"]
failure = next((e for e in reversed(result.tool_events) if e["status"] == "error"), None)
lines: list[str] = []
+9 -5
View File
@@ -5,10 +5,10 @@ from __future__ import annotations
import difflib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, cast
from nanobot.agent.tools.base import ToolResult, tool_parameters
from nanobot.agent.tools.filesystem import _FsTool
from nanobot.agent.tools.filesystem import _FsTool # pyright: ignore[reportPrivateUsage]
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -134,7 +134,7 @@ class ApplyPatchTool(_FsTool):
async def execute(
self,
edits: list[dict] | None = None,
edits: list[object] | None = None,
dry_run: bool = False,
**kwargs: Any,
) -> str:
@@ -145,9 +145,10 @@ class ApplyPatchTool(_FsTool):
writes: dict[Path, str] = {}
summaries: list[_PatchSummary] = []
for edit in edits:
if not isinstance(edit, dict):
for edit_value in edits:
if not isinstance(edit_value, dict):
raise _PatchError("each edit must be an object")
edit = cast(dict[str, Any], edit_value)
raw_path = edit.get("path")
if not isinstance(raw_path, str):
raise _PatchError("path required for edit")
@@ -161,6 +162,7 @@ class ApplyPatchTool(_FsTool):
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for add: {path}")
new_text = cast(str, new_text)
pending = writes.get(source)
if pending is not None:
@@ -204,9 +206,11 @@ class ApplyPatchTool(_FsTool):
old_text = edit.get("old_text") or ""
if not old_text:
raise _PatchError(f"old_text required for replace: {path}")
old_text = cast(str, old_text)
new_text = edit.get("new_text")
if new_text is None:
raise _PatchError(f"new_text required for replace: {path}")
new_text = cast(str, new_text)
pending = writes.get(source)
if pending is not None:
+31 -20
View File
@@ -5,7 +5,7 @@ import typing
from abc import ABC, abstractmethod
from collections.abc import Callable
from copy import deepcopy
from typing import Any, TypeVar
from typing import Any, TypeVar, cast
if typing.TYPE_CHECKING:
from pydantic import BaseModel
@@ -38,8 +38,9 @@ class Schema(ABC):
def resolve_json_schema_type(t: Any) -> str | None:
"""Resolve the non-null type name from JSON Schema ``type`` (e.g. ``['string','null']`` -> ``'string'``)."""
if isinstance(t, list):
return next((x for x in t if x != "null"), None)
return t # type: ignore[return-value]
types = cast(list[Any], t)
return cast(str | None, next((x for x in types if x != "null"), None))
return cast(str | None, t)
@staticmethod
def subpath(path: str, key: str) -> str:
@@ -76,33 +77,41 @@ class Schema(ABC):
if "maximum" in schema and val > schema["maximum"]:
errors.append(f"{label} must be <= {schema['maximum']}")
if t == "string":
if "minLength" in schema and len(val) < schema["minLength"]:
string_value = cast(str, val)
if "minLength" in schema and len(string_value) < schema["minLength"]:
errors.append(f"{label} must be at least {schema['minLength']} chars")
if "maxLength" in schema and len(val) > schema["maxLength"]:
if "maxLength" in schema and len(string_value) > schema["maxLength"]:
errors.append(f"{label} must be at most {schema['maxLength']} chars")
if t == "object":
props = schema.get("properties", {})
for k in schema.get("required", []):
if k not in val:
object_value = cast(dict[str, Any], val)
props = cast(dict[str, Any], schema.get("properties", {}))
required = cast(list[Any], schema.get("required", []))
for k in required:
if k not in object_value:
errors.append(f"missing required {Schema.subpath(path, k)}")
additional = schema.get("additionalProperties", True)
for k, v in val.items():
for k, v in object_value.items():
if k in props:
errors.extend(Schema.validate_json_schema_value(v, props[k], Schema.subpath(path, k)))
elif additional is False:
errors.append(f"unexpected parameter {Schema.subpath(path, k)}")
elif isinstance(additional, dict):
errors.extend(
Schema.validate_json_schema_value(v, additional, Schema.subpath(path, k))
Schema.validate_json_schema_value(
v,
cast(dict[str, Any], additional),
Schema.subpath(path, k),
)
)
if t == "array":
if "minItems" in schema and len(val) < schema["minItems"]:
array_value = cast(list[Any], val)
if "minItems" in schema and len(array_value) < schema["minItems"]:
errors.append(f"{label} must have at least {schema['minItems']} items")
if "maxItems" in schema and len(val) > schema["maxItems"]:
if "maxItems" in schema and len(array_value) > schema["maxItems"]:
errors.append(f"{label} must be at most {schema['maxItems']} items")
if "items" in schema:
prefix = f"{path}[{{}}]" if path else "[{}]"
for i, item in enumerate(val):
for i, item in enumerate(array_value):
errors.extend(
Schema.validate_json_schema_value(item, schema["items"], prefix.format(i))
)
@@ -114,9 +123,9 @@ class Schema(ABC):
# Try to_json_schema first: Schema instances must be distinguished from dicts that are already JSON Schema
to_js = getattr(value, "to_json_schema", None)
if callable(to_js):
return to_js()
return cast(dict[str, Any], to_js())
if isinstance(value, dict):
return value
return cast(dict[str, Any], value)
raise TypeError(f"Expected schema object or dict, got {type(value).__name__}")
@abstractmethod
@@ -223,14 +232,15 @@ class Tool(ABC):
def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]:
if not isinstance(obj, dict):
return obj
props = schema.get("properties", {})
props = cast(dict[str, Any], schema.get("properties", {}))
additional = schema.get("additionalProperties")
casted: dict[str, Any] = {}
for k, v in obj.items():
object_value = cast(dict[str, Any], obj)
for k, v in object_value.items():
if k in props:
casted[k] = self._cast_value(v, props[k])
elif isinstance(additional, dict):
casted[k] = self._cast_value(v, additional)
casted[k] = self._cast_value(v, cast(dict[str, Any], additional))
else:
casted[k] = v
return casted
@@ -273,7 +283,8 @@ class Tool(ABC):
if t == "array" and isinstance(val, list):
items = schema.get("items")
return [self._cast_value(x, items) for x in val] if items else val
array_value = cast(list[Any], val)
return [self._cast_value(x, items) for x in array_value] if items else array_value
if t == "object" and isinstance(val, dict):
return self._cast_object(val, schema)
@@ -282,7 +293,7 @@ class Tool(ABC):
def validate_params(self, params: dict[str, Any]) -> list[str]:
"""Validate against JSON schema; empty list means valid."""
if not isinstance(params, dict):
if not isinstance(cast(object, params), dict):
return [f"parameters must be an object, got {type(params).__name__}"]
schema = self.parameters or {}
if schema.get("type", "object") != "object":
+5 -4
View File
@@ -1,14 +1,15 @@
"""Controlled runner for installed CLI Apps."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from pathlib import Path
from typing import Any
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext
from nanobot.agent.tools.context import RequestContext, ToolContext
from nanobot.agent.tools.schema import (
ArraySchema,
BooleanSchema,
@@ -66,11 +67,11 @@ class CliAppsTool(Tool):
return CliAppsToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.cli_apps.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
cfg = ctx.config.cli_apps
return cls(
workspace=Path(ctx.workspace),
+21 -11
View File
@@ -8,6 +8,16 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Protocol, runtime_checkable
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.exec_session import ExecSessionManager
from nanobot.agent.tools.file_state import FileStates
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus
from nanobot.config.schema import ProviderConfig, ToolsConfig
from nanobot.cron.service import CronService
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import WorkspaceSandboxStatus
from nanobot.session.manager import SessionManager
from nanobot.utils.llm_runtime import LLMRuntime
_CURRENT_REQUEST_CONTEXT: ContextVar["RequestContext | None"] = ContextVar(
@@ -67,16 +77,16 @@ def current_request_session_key() -> str | None:
@dataclass
class ToolContext:
config: Any
config: ToolsConfig
workspace: str
bus: Any | None = None
subagent_manager: Any | None = None
cron_service: Any | None = None
exec_session_manager: Any | None = None
sessions: Any | None = None
file_state_store: Any = field(default=None)
provider_snapshot_loader: Callable[[], Any] | None = None
image_generation_provider_configs: dict[str, Any] | None = None
bus: MessageBus | None = None
subagent_manager: SubagentManager | None = None
cron_service: CronService | None = None
exec_session_manager: ExecSessionManager | None = None
sessions: SessionManager | None = None
file_state_store: FileStates | None = None
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None = None
image_generation_provider_configs: dict[str, ProviderConfig] | None = None
timezone: str = "UTC"
workspace_sandbox: Any | None = None
runtime_events: Any | None = None
workspace_sandbox: WorkspaceSandboxStatus | None = None
runtime_events: RuntimeEventBus | None = None
+13 -8
View File
@@ -1,13 +1,15 @@
"""Cron tool for scheduling reminders and tasks."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from contextvars import ContextVar
from contextvars import ContextVar, Token
from datetime import datetime
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.schema import (
IntegerSchema,
StringSchema,
@@ -60,12 +62,15 @@ class CronTool(Tool):
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.cron_service is not None
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(cron_service=ctx.cron_service, default_timezone=ctx.timezone)
def create(cls, ctx: ToolContext) -> Tool:
cron_service = ctx.cron_service
if cron_service is None:
raise RuntimeError("CronTool requires an initialized cron service")
return cls(cron_service=cron_service, default_timezone=ctx.timezone)
@staticmethod
def _request_route() -> tuple[str, str, str, dict[str, Any]]:
@@ -79,11 +84,11 @@ class CronTool(Tool):
)
return session_key, ctx.channel or "", ctx.chat_id or "", dict(ctx.metadata or {})
def set_cron_context(self, active: bool):
def set_cron_context(self, active: bool) -> Token[bool]:
"""Mark whether the tool is executing inside a cron job callback."""
return self._in_cron_context.set(active)
def reset_cron_context(self, token) -> None:
def reset_cron_context(self, token: Token[bool]) -> None:
"""Restore previous cron context."""
self._in_cron_context.reset(token)
@@ -257,7 +262,7 @@ class CronTool(Tool):
jobs = self._cron.list_jobs()
if not jobs:
return "No scheduled jobs."
lines = []
lines: list[str] = []
for j in jobs:
timing = self._format_timing(j.schedule)
parts = [f"- {j.name} (id: {j.id}, {timing})"]
+22 -22
View File
@@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -151,8 +151,8 @@ class _ExecSession:
timeout=2.0,
)
# Safety-net reap after normal exit.
from nanobot.agent.tools.shell import _reap_pid
_reap_pid(self.process.pid)
from nanobot.agent.tools.shell import _reap_pid # pyright: ignore[reportPrivateUsage]
_reap_pid(self.process.pid) # pyright: ignore[reportPrivateUsage]
elif yield_time_ms > 0:
await self._wait_for_buffered_output()
@@ -177,9 +177,9 @@ class _ExecSession:
try:
if self._process_tree:
await ExecTool._kill_process_tree(self.process)
await ExecTool._kill_process_tree(self.process) # pyright: ignore[reportPrivateUsage]
else:
await ExecTool._kill_process(self.process)
await ExecTool._kill_process(self.process) # pyright: ignore[reportPrivateUsage]
finally:
with suppress(asyncio.TimeoutError):
await asyncio.wait_for(
@@ -311,13 +311,13 @@ class ExecSessionManager:
"""Terminate and remove all active sessions during shutdown."""
async with self._lock:
self._closed = True
sessions = list(self._sessions.values())
sessions: list[_ExecSession] = list(self._sessions.values())
self._sessions.clear()
results = await asyncio.gather(
results: list[None | BaseException] = list(await asyncio.gather(
*(session.kill() for session in sessions),
return_exceptions=True,
)
failures = [
))
failures: list[tuple[_ExecSession, BaseException]] = [
(session, result)
for session, result in zip(sessions, results, strict=True)
if isinstance(result, BaseException)
@@ -337,15 +337,15 @@ class ExecSessionManager:
async def terminate_by_owner(self, owner_session_key: str) -> int:
"""Terminate all sessions owned by owner_session_key. Returns count."""
async with self._lock:
victims = []
victims: list[_ExecSession] = []
for sid, s in list(self._sessions.items()):
if s.owner_session_key == owner_session_key:
victims.append(self._sessions.pop(sid))
results = await asyncio.gather(
results: list[None | BaseException] = list(await asyncio.gather(
*(s.kill() for s in victims),
return_exceptions=True,
)
failures = [
))
failures: list[tuple[_ExecSession, BaseException]] = [
(session, result)
for session, result in zip(victims, results, strict=True)
if isinstance(result, BaseException)
@@ -384,7 +384,7 @@ class ExecSessionManager:
) -> asyncio.subprocess.Process:
from nanobot.agent.tools.shell import ExecTool
return await ExecTool._spawn(
return await ExecTool._spawn( # pyright: ignore[reportPrivateUsage]
command, cwd, env, shell_program, login,
stdin=asyncio.subprocess.PIPE,
process_tree=True,
@@ -489,7 +489,7 @@ class WriteStdinTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable
def __init__(
@@ -500,8 +500,8 @@ class WriteStdinTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None))
def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=ctx.exec_session_manager)
@property
def exclusive(self) -> bool:
@@ -522,7 +522,7 @@ class WriteStdinTool(Tool):
"Do not use this to start new commands; start them with exec."
)
async def execute(
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
self,
session_id: str,
chars: str | None = None,
@@ -633,7 +633,7 @@ class ListExecSessionsTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable
def __init__(
@@ -644,8 +644,8 @@ class ListExecSessionsTool(Tool):
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(manager=getattr(ctx, "exec_session_manager", None))
def create(cls, ctx: ToolContext) -> Tool:
return cls(manager=ctx.exec_session_manager)
@property
def name(self) -> str:
@@ -671,7 +671,7 @@ class ListExecSessionsTool(Tool):
)
if not sessions:
return "No active exec sessions."
lines = []
lines: list[str] = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
+5 -1
View File
@@ -125,6 +125,10 @@ class FileStates:
"""Return the raw ReadState entry for a path, or None."""
return self._state.get(str(Path(path).resolve()))
def raw_state(self) -> dict[str, ReadState]:
"""Return the mutable backing map for legacy compatibility."""
return self._state
def clear(self) -> None:
"""Clear all tracked state (useful for testing)."""
self._state.clear()
@@ -201,5 +205,5 @@ def clear() -> None:
# so existing imports keep working.
def __getattr__(name: str):
if name == "_state":
return _default._state
return _default.raw_state()
raise AttributeError(name)
+7 -3
View File
@@ -1,5 +1,7 @@
"""File system tools: read, write, edit, list."""
# pyright: reportPrivateUsage=false, reportUnusedFunction=false
import difflib
import mimetypes
import os
@@ -8,6 +10,7 @@ from pathlib import Path
from typing import Any
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import (
@@ -37,7 +40,7 @@ class _FsTool(Tool):
return FileToolsConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.file.enable
def __init__(
@@ -77,7 +80,7 @@ class _FsTool(Tool):
self._fallback_file_states = FileStates()
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
agent_workspace = Path(ctx.workspace)
@@ -408,7 +411,8 @@ class ReadFileTool(_FsTool):
result = "\n".join(numbered)
if len(result) > self._MAX_CHARS:
trimmed, chars = [], 0
trimmed: list[str] = []
chars = 0
for line in numbered:
chars += len(line) + 1
if chars > self._MAX_CHARS:
+25 -19
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from pathlib import Path
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from loguru import logger
from pydantic import Field
@@ -23,6 +23,7 @@ from nanobot.bus.events import (
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.config.paths import get_media_dir
from nanobot.config_base import Base
from nanobot.providers.image_generation import (
@@ -41,6 +42,7 @@ from nanobot.utils.artifacts import (
from nanobot.utils.helpers import detect_image_mime
if TYPE_CHECKING:
from nanobot.agent.tools.context import ToolContext
from nanobot.config.schema import ProviderConfig
@@ -89,11 +91,11 @@ class ImageGenerationTool(Tool):
return ImageGenerationToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.image_generation.enabled
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
return cls(
workspace=ctx.workspace,
config=ctx.config.image_generation,
@@ -134,12 +136,14 @@ class ImageGenerationTool(Tool):
cls = get_image_gen_provider(self.config.provider)
if cls is None:
return None
kwargs = {
"api_key": provider.api_key if provider else None,
"api_base": provider.api_base if provider else None,
"extra_headers": provider.extra_headers if provider else None,
"extra_body": provider.extra_body if provider else None,
"proxy": provider.proxy if provider else None,
kwargs: dict[str, Any] = {
"api_key": provider.api_key if provider and isinstance(provider.api_key, str) else None,
"api_base": provider.api_base if provider and isinstance(provider.api_base, str) else None,
"extra_headers": provider.extra_headers
if provider and isinstance(provider.extra_headers, dict) else None,
"extra_body": provider.extra_body
if provider and isinstance(provider.extra_body, dict) else None,
"proxy": provider.proxy if provider and isinstance(provider.proxy, str) else None,
}
return cls(**kwargs)
@@ -172,7 +176,7 @@ class ImageGenerationTool(Tool):
return []
return [self._resolve_reference_image(value) for value in values if value]
async def execute(
async def execute( # pyright: ignore[reportIncompatibleMethodOverride]
self,
prompt: str,
reference_images: list[str] | None = None,
@@ -238,7 +242,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
}
next_tool = (
ImageGenerationTool(
ImageGenerationTool( # pyright: ignore[reportAbstractUsage]
workspace=state.workspace,
config=tool_config,
provider_configs=provider_configs,
@@ -271,7 +275,7 @@ async def reload_image_generation_tool(state: Any, registry: ToolRegistry) -> di
async def request_image_generation_reload(
bus: Any,
bus: MessageBus,
*,
timeout: float = 5.0,
) -> dict[str, Any]:
@@ -298,11 +302,13 @@ async def request_image_generation_reload(
"message": "Image generation hot reload timed out.",
"requires_restart": True,
}
return result if isinstance(result, dict) else {
"ok": False,
"message": "Image generation hot reload returned an unexpected response.",
"requires_restart": True,
}
if not isinstance(cast(object, result), dict):
return {
"ok": False,
"message": "Image generation hot reload returned an unexpected response.",
"requires_restart": True,
}
return result
async def handle_runtime_control(
@@ -311,7 +317,7 @@ async def handle_runtime_control(
registry: ToolRegistry,
) -> bool:
"""Handle an in-process image generation reload request."""
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
metadata = msg.metadata
if metadata.get(INBOUND_META_RUNTIME_CONTROL) != RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD:
return False
@@ -327,5 +333,5 @@ async def handle_runtime_control(
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result)
cast(asyncio.Future[Any], ack).set_result(result)
return True
+9 -3
View File
@@ -1,16 +1,22 @@
"""Tool discovery and registration via package scanning."""
# pyright: reportIncompatibleVariableOverride=false
from __future__ import annotations
import importlib
import pkgutil
from importlib.metadata import entry_points
from typing import Any
from typing import TYPE_CHECKING, Any
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.registry import ToolRegistry
if TYPE_CHECKING:
from nanobot.agent.tools.context import RequestContext, ToolContext
_SKIP_MODULES = frozenset({
"base", "schema", "registry", "context", "loader", "config",
"file_state", "sandbox", "mcp", "__init__", "runtime_state",
@@ -83,7 +89,7 @@ class ToolLoader:
self._plugins = plugins
return plugins
def load(self, ctx: Any, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
def load(self, ctx: ToolContext, registry: ToolRegistry, *, scope: str = "core") -> list[str]:
registered: list[str] = []
builtin_names: set[str] = set()
sources = [(self.discover(), False), (self._discover_plugins().values(), True)]
@@ -157,7 +163,7 @@ class _LegacyErrorPrefixTool(Tool):
def config_key(self) -> str:
return getattr(self._wrapped, "config_key", "")
def set_context(self, ctx: Any) -> None:
def set_context(self, ctx: RequestContext) -> None:
set_context = getattr(self._wrapped, "set_context", None)
if callable(set_context):
set_context(ctx)
+19 -15
View File
@@ -1,5 +1,7 @@
"""Sustained-goal tools with explicit user opt-in at the execution boundary."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from copy import deepcopy
@@ -11,7 +13,7 @@ from nanobot.agent.goal_permission import (
revoke_goal_mutation_permission,
)
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import RequestContext, current_request_context
from nanobot.agent.tools.context import RequestContext, ToolContext, current_request_context
from nanobot.agent.tools.schema import StringSchema, tool_parameters_schema
from nanobot.bus.runtime_events import GoalStateChanged, RuntimeEventBus, RuntimeEventContext
from nanobot.runtime_context import RuntimeContextBlock, wrap_runtime_context_lines
@@ -132,23 +134,24 @@ class CreateGoalTool(Tool, _GoalToolsMixin):
def __init__(
self,
sessions: Any,
sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
def create(cls, ctx: ToolContext) -> Tool:
sess = ctx.sessions
if sess is None:
raise RuntimeError("CreateGoalTool requires an initialized session manager")
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
runtime_events=ctx.runtime_events,
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.sessions is not None
@property
def name(self) -> str:
@@ -262,23 +265,24 @@ class UpdateGoalTool(Tool, _GoalToolsMixin):
def __init__(
self,
sessions: Any,
sessions: SessionManager,
runtime_events: RuntimeEventBus | None = None,
) -> None:
_GoalToolsMixin.__init__(self, sessions, runtime_events)
@classmethod
def create(cls, ctx: Any) -> Tool:
sess = getattr(ctx, "sessions", None)
assert sess is not None
def create(cls, ctx: ToolContext) -> Tool:
sess = ctx.sessions
if sess is None:
raise RuntimeError("UpdateGoalTool requires an initialized session manager")
return cls(
sessions=sess,
runtime_events=getattr(ctx, "runtime_events", None),
runtime_events=ctx.runtime_events,
)
@classmethod
def enabled(cls, ctx: Any) -> bool:
return getattr(ctx, "sessions", None) is not None
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.sessions is not None
@property
def name(self) -> str:
+99 -46
View File
@@ -7,9 +7,9 @@ import os
import re
import shutil
import urllib.parse
from collections.abc import Awaitable, Callable
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, suppress
from typing import Any, Mapping, Protocol
from typing import TYPE_CHECKING, Any, Mapping, Protocol, cast
from weakref import WeakKeyDictionary
import httpx
@@ -23,6 +23,7 @@ from nanobot.bus.events import (
RUNTIME_CONTROL_MCP_RELOAD,
InboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.security.network import (
PinnedDNSAsyncTransport,
env_proxy_applies_to_url,
@@ -32,6 +33,13 @@ from nanobot.security.network import (
)
from nanobot.utils.cancellation import task_is_cancelling
if TYPE_CHECKING:
from mcp import ClientSession
from mcp.types import Prompt, Resource
from mcp.types import Tool as MCPToolDefinition
from nanobot.config.schema import MCPServerConfig
# Transient connection errors that warrant a single retry.
# These typically happen when an MCP server restarts or a network
# connection is interrupted between calls.
@@ -92,7 +100,7 @@ def _mcp_jsonrpc_payload(message: Any) -> Any:
def _payload_value(payload: Any, key: str) -> Any:
if isinstance(payload, Mapping):
return payload.get(key)
return cast(Mapping[str, Any], payload).get(key)
return getattr(payload, key, None)
@@ -106,7 +114,7 @@ class _MalformedProgressNotificationFilter:
def __init__(self, read_stream: Any, server_name: str) -> None:
self._read_stream = read_stream
self._server_name = server_name
self._iterator: Any | None = None
self._iterator: AsyncIterator[Any] | None = None
async def __aenter__(self) -> "_MalformedProgressNotificationFilter":
await self._read_stream.__aenter__()
@@ -120,11 +128,13 @@ class _MalformedProgressNotificationFilter:
return self
async def __anext__(self) -> Any:
if self._iterator is None:
self._iterator = self._read_stream.__aiter__()
iterator = self._iterator
if iterator is None:
iterator = self._read_stream.__aiter__()
self._iterator = iterator
while True:
message = await self._iterator.__anext__()
message = await anext(iterator)
if _is_malformed_mcp_progress_notification(message):
logger.debug(
"MCP server '{}': dropped progress notification without progressToken",
@@ -241,8 +251,8 @@ def _redact_url(url: str) -> str:
return "<redacted-url>"
def _pinned_transport_kwargs() -> dict[str, object]:
kwargs: dict[str, object] = {"transport": PinnedDNSAsyncTransport()}
def _pinned_transport_kwargs() -> dict[str, Any]:
kwargs: dict[str, Any] = {"transport": PinnedDNSAsyncTransport()}
mounts = httpx_env_proxy_mounts()
if mounts:
kwargs["mounts"] = mounts
@@ -302,13 +312,14 @@ def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None
non_null: list[dict[str, Any]] = []
saw_null = False
for option in options:
for option in cast(list[object], options):
if not isinstance(option, dict):
return None
if option.get("type") == "null":
option_schema = cast(dict[str, Any], option)
if option_schema.get("type") == "null":
saw_null = True
continue
non_null.append(option)
non_null.append(option_schema)
if saw_null and len(non_null) == 1:
return non_null[0], True
@@ -330,9 +341,9 @@ def _resolve_local_schema_ref(root: dict[str, Any], ref: str) -> Any:
for raw_part in pointer[1:].split("/"):
part = raw_part.replace("~1", "/").replace("~0", "~")
if isinstance(current, dict):
current = current[part]
current = cast(dict[str, Any], current)[part]
elif isinstance(current, list):
current = current[int(part)]
current = cast(list[Any], current)[int(part)]
else:
raise KeyError(part)
return current
@@ -345,14 +356,15 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
def rewrite(value: Any) -> Any:
if isinstance(value, list):
return [rewrite(item) for item in value]
return [rewrite(item) for item in cast(list[Any], value)]
if not isinstance(value, dict):
return value
rewritten = dict(value)
ref = rewritten.get("$ref")
rewritten = dict(cast(dict[str, Any], value))
raw_ref = rewritten.get("$ref")
ref = raw_ref if isinstance(raw_ref, str) else None
is_rewritable_ref = False
if isinstance(ref, str) and not ref.startswith("#/$defs/"):
if ref is not None and not ref.startswith("#/$defs/"):
try:
pointer = urllib.parse.unquote(ref[1:], errors="strict")
except (UnicodeDecodeError, ValueError):
@@ -362,6 +374,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
not pointer or pointer.startswith("/")
)
if is_rewritable_ref:
assert ref is not None
name = rewritten_refs.get(ref)
if name is None:
try:
@@ -369,7 +382,6 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
except (KeyError, IndexError, TypeError, UnicodeDecodeError, ValueError):
logger.warning("MCP tool schema contains an unresolved local $ref: {}", ref)
else:
assert isinstance(ref, str)
name = f"ref_{hashlib.sha256(ref.encode()).hexdigest()[:12]}"
existing_defs = schema.get("$defs")
while isinstance(existing_defs, dict) and name in existing_defs:
@@ -383,7 +395,7 @@ def _rewrite_local_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
return {key: rewrite(item) for key, item in rewritten.items()}
result = rewrite(schema)
result = cast(dict[str, Any], rewrite(schema))
if generated_defs:
existing_defs = result.get("$defs")
result["$defs"] = {
@@ -398,8 +410,9 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized = dict(schema)
raw_type = normalized.get("type")
if isinstance(raw_type, list):
non_null = [item for item in raw_type if item != "null"]
if "null" in raw_type and len(non_null) == 1:
type_values = cast(list[Any], raw_type)
non_null = [item for item in type_values if item != "null"]
if "null" in type_values and len(non_null) == 1:
normalized["type"] = non_null[0]
normalized["nullable"] = True
@@ -413,19 +426,28 @@ def _normalize_nullable_schema(schema: dict[str, Any]) -> dict[str, Any]:
normalized["nullable"] = True
break
if isinstance(normalized.get("properties"), dict):
properties = normalized.get("properties")
if isinstance(properties, dict):
property_schemas = cast(dict[str, Any], properties)
normalized["properties"] = {
name: _normalize_nullable_schema(prop) if isinstance(prop, dict) else prop
for name, prop in normalized["properties"].items()
name: (
_normalize_nullable_schema(cast(dict[str, Any], prop))
if isinstance(prop, dict)
else prop
)
for name, prop in property_schemas.items()
}
if isinstance(normalized.get("items"), dict):
normalized["items"] = _normalize_nullable_schema(normalized["items"])
if isinstance(normalized.get("$defs"), dict):
items = normalized.get("items")
if isinstance(items, dict):
normalized["items"] = _normalize_nullable_schema(cast(dict[str, Any], items))
definitions = normalized.get("$defs")
if isinstance(definitions, dict):
definition_schemas = cast(dict[str, Any], definitions)
normalized["$defs"] = {
name: _normalize_nullable_schema(definition)
name: _normalize_nullable_schema(cast(dict[str, Any], definition))
if isinstance(definition, dict)
else definition
for name, definition in normalized["$defs"].items()
for name, definition in definition_schemas.items()
}
if normalized.get("type") == "object":
@@ -438,15 +460,19 @@ def _normalize_schema_for_openai(schema: Any) -> dict[str, Any]:
"""Normalize MCP JSON Schema patterns for tool definitions."""
if not isinstance(schema, dict):
return {"type": "object", "properties": {}}
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema))
schema_mapping = cast(dict[str, Any], schema)
return _normalize_nullable_schema(_rewrite_local_schema_refs(schema_mapping))
class _MCPWrapperBase(Tool):
"""Common reconnect handling for wrappers bound to one MCP server session."""
_plugin_discoverable = False
_session: "ClientSession"
_server_name: str
_name: str
def _set_mcp_connection(self, session: Any, server_name: str) -> None:
def _set_mcp_connection(self, session: "ClientSession", server_name: str) -> None:
self._session = session
self._server_name = server_name
self._reconnect: _ReconnectCallback | None = None
@@ -500,9 +526,10 @@ def _image_block_data_url(block: Any, types: Any) -> str | None:
if embedded_cls is not None and isinstance(block, embedded_cls):
resource = getattr(block, "resource", None)
if blob_cls is not None and isinstance(resource, blob_cls):
mime = getattr(resource, "mimeType", None) or ""
blob_resource = cast(Any, resource)
mime = getattr(blob_resource, "mimeType", None) or ""
if isinstance(mime, str) and mime.startswith("image/"):
return f"data:{mime};base64,{resource.blob}"
return f"data:{mime};base64,{blob_resource.blob}"
return None
@@ -533,7 +560,13 @@ class MCPToolWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(self, session, server_name: str, tool_def, tool_timeout: int = 30):
def __init__(
self,
session: "ClientSession",
server_name: str,
tool_def: "MCPToolDefinition",
tool_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
self._original_name = tool_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_{tool_def.name}")
@@ -689,7 +722,13 @@ class MCPResourceWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(self, session, server_name: str, resource_def, resource_timeout: int = 30):
def __init__(
self,
session: "ClientSession",
server_name: str,
resource_def: "Resource",
resource_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
self._uri = resource_def.uri
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_resource_{resource_def.name}")
@@ -775,7 +814,7 @@ class MCPResourceWrapper(_MCPWrapperBase):
for block in result.contents:
if isinstance(block, types.TextResourceContents):
parts.append(block.text)
elif isinstance(block, types.BlobResourceContents):
elif isinstance(cast(object, block), types.BlobResourceContents):
parts.append(f"[Binary resource: {len(block.blob)} bytes]")
else:
parts.append(str(block))
@@ -787,7 +826,13 @@ class MCPPromptWrapper(_MCPWrapperBase):
_plugin_discoverable = False
def __init__(self, session, server_name: str, prompt_def, prompt_timeout: int = 30):
def __init__(
self,
session: "ClientSession",
server_name: str,
prompt_def: "Prompt",
prompt_timeout: int = 30,
):
self._set_mcp_connection(session, server_name)
self._prompt_name = prompt_def.name
self._name = _sanitize_mcp_tool_name(f"mcp_{server_name}_prompt_{prompt_def.name}")
@@ -916,7 +961,7 @@ class MCPPromptWrapper(_MCPWrapperBase):
async def connect_mcp_servers(
mcp_servers: dict, registry: ToolRegistry
mcp_servers: "dict[str, MCPServerConfig]", registry: ToolRegistry
) -> dict[str, MCPConnection]:
"""Connect to configured MCP servers and register their tools, resources, prompts.
@@ -929,7 +974,9 @@ async def connect_mcp_servers(
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamable_http_client
async def open_single_server(name: str, cfg) -> tuple[str, AsyncExitStack | None]:
async def open_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, AsyncExitStack | None]:
server_stack = AsyncExitStack()
await server_stack.__aenter__()
@@ -1148,7 +1195,9 @@ async def connect_mcp_servers(
await server_stack.aclose()
return name, None
async def connect_single_server(name: str, cfg) -> tuple[str, MCPConnection | None]:
async def connect_single_server(
name: str, cfg: "MCPServerConfig"
) -> tuple[str, MCPConnection | None]:
loop = asyncio.get_running_loop()
ready: asyncio.Future[bool] = loop.create_future()
close_requested = asyncio.Event()
@@ -1192,7 +1241,7 @@ async def connect_mcp_servers(
except Exception as e:
logger.exception("MCP server '{}' connection failed: {}", name, e)
continue
if result is not None and result[1] is not None:
if result[1] is not None:
server_stacks[result[0]] = result[1]
return server_stacks
@@ -1335,7 +1384,11 @@ async def reload_servers(state: Any, registry: ToolRegistry) -> dict[str, Any]:
}
async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, Any]:
async def request_mcp_reload(
bus: MessageBus,
*,
timeout: float = 15.0,
) -> dict[str, Any]:
"""Ask the running agent loop to reconcile live MCP connections."""
loop = asyncio.get_running_loop()
ack: asyncio.Future[dict[str, Any]] = loop.create_future()
@@ -1359,7 +1412,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
"message": "MCP hot reload timed out. Restart nanobot to pick up changes.",
"requires_restart": True,
}
return result if isinstance(result, dict) else {
return result if isinstance(cast(object, result), dict) else {
"ok": False,
"message": "MCP hot reload returned an unexpected response.",
"requires_restart": True,
@@ -1367,7 +1420,7 @@ async def request_mcp_reload(bus: Any, *, timeout: float = 15.0) -> dict[str, An
async def handle_runtime_control(state: Any, msg: InboundMessage, registry: ToolRegistry) -> bool:
metadata = msg.metadata if isinstance(msg.metadata, dict) else {}
metadata = msg.metadata if isinstance(cast(object, msg.metadata), dict) else {}
control = metadata.get(INBOUND_META_RUNTIME_CONTROL)
if control != RUNTIME_CONTROL_MCP_RELOAD:
return False
@@ -1384,7 +1437,7 @@ async def handle_runtime_control(state: Any, msg: InboundMessage, registry: Tool
"error": str(exc),
}
if isinstance(ack, asyncio.Future) and not ack.done():
ack.set_result(result)
cast(asyncio.Future[dict[str, Any]], ack).set_result(result)
return True
+23 -13
View File
@@ -1,13 +1,15 @@
"""Message tool for sending messages to users."""
from contextvars import ContextVar
# pyright: reportIncompatibleMethodOverride=false
from contextvars import ContextVar, Token
from pathlib import Path
from typing import Any, Awaitable, Callable
from typing import Any, Awaitable, Callable, cast
from loguru import logger
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_context
from nanobot.agent.tools.context import ToolContext, current_request_context
from nanobot.agent.tools.path_utils import resolve_workspace_path
from nanobot.agent.tools.schema import ArraySchema, StringSchema, tool_parameters_schema
from nanobot.bus.events import OutboundMessage
@@ -73,7 +75,7 @@ class MessageTool(Tool):
)
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
send_callback = ctx.bus.publish_outbound if ctx.bus else None
return cls(
send_callback=send_callback,
@@ -89,11 +91,11 @@ class MessageTool(Tool):
"""Reset per-turn send tracking."""
self._sent_in_turn = False
def set_suppress_delivery(self, active: bool):
def set_suppress_delivery(self, active: bool) -> Token[bool]:
"""Acknowledge but don't deliver tool sends (heartbeat internal check)."""
return self._suppress_delivery_var.set(active)
def reset_suppress_delivery(self, token) -> None:
def reset_suppress_delivery(self, token: Token[bool]) -> None:
"""Restore previous delivery-suppression state."""
self._suppress_delivery_var.reset(token)
@@ -148,19 +150,23 @@ class MessageTool(Tool):
chat_id: str | None = None,
message_id: str | None = None,
media: list[str] | None = None,
buttons: list[list[str]] | None = None,
buttons: Any = None,
**kwargs: Any,
) -> str:
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
from nanobot.utils.helpers import strip_think
content = strip_think(content)
button_rows: list[list[str]] | None = None
if buttons is not None:
if not isinstance(buttons, list) or any(
not isinstance(row, list) or any(not isinstance(label, str) for label in row)
for row in buttons
raw_buttons = cast(list[Any], buttons) if isinstance(buttons, list) else None
if raw_buttons is None or any(
not isinstance(row, list)
or any(not isinstance(label, str) for label in cast(list[Any], row))
for row in raw_buttons
):
return ToolResult.error("Error: buttons must be a list of list of strings")
button_rows = cast(list[list[str]], raw_buttons)
request_ctx = current_request_context()
default_channel = (
request_ctx.channel if request_ctx is not None else self._fallback_channel
@@ -228,7 +234,7 @@ class MessageTool(Tool):
chat_id=chat_id,
content=content,
media=media or [],
buttons=buttons or [],
buttons=button_rows or [],
metadata=metadata,
)
@@ -241,7 +247,11 @@ class MessageTool(Tool):
if channel == default_channel and chat_id == default_chat_id:
self._sent_in_turn = True
media_info = f" with {len(media)} attachments" if media else ""
button_info = f" with {sum(len(row) for row in buttons)} button(s)" if buttons else ""
button_info = (
f" with {sum(len(row) for row in button_rows)} button(s)"
if button_rows
else ""
)
return f"Message sent to {channel}:{chat_id}{media_info}{button_info}"
except Exception as e:
return ToolResult.error(f"Error sending message: {str(e)}")
+9 -6
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
from nanobot.agent.tools.base import Tool, ToolResult
from nanobot.agent.tools.context import ContextAware, current_request_context
@@ -77,7 +77,7 @@ class ToolRegistry:
"""Extract a normalized tool name from either OpenAI or flat schemas."""
fn = schema.get("function")
if isinstance(fn, dict):
name = fn.get("name")
name = cast(dict[str, Any], fn).get("name")
if isinstance(name, str):
return name
name = schema.get("name")
@@ -140,7 +140,7 @@ class ToolRegistry:
)
)
cast_params = tool.cast_params(params)
cast_params = tool.cast_params(cast(dict[str, Any], params))
errors = tool.validate_params(cast_params)
if errors:
return tool, cast_params, (
@@ -176,12 +176,15 @@ class ToolRegistry:
@classmethod
def _unwrap_arguments_payload(cls, tool: Tool, params: Any) -> Any:
if not isinstance(params, dict) or set(params) != {"arguments"}:
if not isinstance(params, dict):
return params
arguments_payload = cast(dict[str, Any], params)
if set(arguments_payload) != {"arguments"}:
return arguments_payload
properties = (tool.parameters or {}).get("properties", {})
if isinstance(properties, dict) and "arguments" in properties:
return params
return cls._coerce_argument_value(params.get("arguments"))
return arguments_payload
return cls._coerce_argument_value(arguments_payload.get("arguments"))
async def execute(self, name: str, params: Any) -> Any:
"""Execute a tool by name with given parameters."""
+18 -12
View File
@@ -1,6 +1,15 @@
"""RuntimeState protocol: agent loop state exposed to MyTool."""
from typing import Any, Protocol
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.shell import ExecToolConfig
from nanobot.agent.tools.web import WebToolsConfig
from nanobot.utils.llm_runtime import LLMRuntime
class RuntimeState(Protocol):
@@ -25,7 +34,7 @@ class RuntimeState(Protocol):
def tool_names(self) -> list[str]: ...
@property
def workspace(self) -> str: ...
def workspace(self) -> Path: ...
@property
def provider_retry_mode(self) -> str: ...
@@ -37,34 +46,31 @@ class RuntimeState(Protocol):
def context_window_tokens(self) -> int: ...
@property
def web_config(self) -> Any: ...
def web_config(self) -> WebToolsConfig: ...
@property
def exec_config(self) -> Any: ...
def exec_config(self) -> ExecToolConfig: ...
@property
def workspace_sandbox(self) -> Any: ...
@property
def subagents(self) -> Any: ...
def subagents(self) -> SubagentManager: ...
@property
def _runtime_vars(self) -> dict[str, Any]: ...
@property
def _last_usage(self) -> Any: ...
def _last_usage(self) -> dict[str, int]: ...
def _sync_subagent_runtime_limits(self) -> None: ...
def set_runtime_model(self, model: str) -> Any: ...
def set_runtime_model(self, model: str) -> LLMRuntime: ...
def set_runtime_context_window(self, context_window_tokens: int) -> Any: ...
def set_runtime_context_window(self, context_window_tokens: int) -> LLMRuntime: ...
def set_session_model_preset(
self,
session_key: str,
name: str,
) -> Any: ...
) -> LLMRuntime: ...
@property
def model_preset(self) -> str | None: ...
+2
View File
@@ -1,5 +1,7 @@
"""Search tools: file discovery and grep."""
# pyright: reportIncompatibleMethodOverride=false, reportPrivateUsage=false
from __future__ import annotations
import fnmatch
+53 -30
View File
@@ -1,10 +1,14 @@
"""MyTool: runtime state inspection and configuration for the agent loop."""
# RuntimeState intentionally exposes a narrow set of AgentLoop internals to
# this manually registered tool. Tool.execute accepts heterogeneous schemas.
# pyright: reportPrivateUsage=false, reportIncompatibleMethodOverride=false
from __future__ import annotations
import time
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, TypeGuard, cast
from loguru import logger
@@ -15,6 +19,7 @@ from nanobot.config_base import Base
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentStatus
from nanobot.agent.tools.context import ToolContext
class MyToolConfig(Base):
@@ -36,7 +41,7 @@ def _has_real_attr(obj: Any, key: str) -> bool:
return False
def _is_subagent_status(value: Any) -> bool:
def _is_subagent_status(value: object) -> TypeGuard[SubagentStatus]:
from nanobot.agent.subagent import SubagentStatus
return isinstance(value, SubagentStatus)
@@ -53,7 +58,7 @@ class MyTool(Tool):
return MyToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.my.enable
BLOCKED = frozenset({
@@ -205,7 +210,7 @@ class MyTool(Tool):
def _resolve_path(self, path: str) -> tuple[Any, str | None]:
parts = path.split(".")
obj = self._runtime_state
obj: Any = self._runtime_state
for part in parts:
if part in self._DENIED_ATTRS or part.startswith("__"):
return None, f"'{part}' is not accessible"
@@ -215,8 +220,9 @@ class MyTool(Tool):
return None, f"'{part}' is not accessible"
try:
if isinstance(obj, Mapping):
if part in obj:
obj = obj[part]
mapping = cast(Mapping[str, Any], obj)
if part in mapping:
obj = mapping[part]
else:
return None, f"'{part}' not found in mapping"
else:
@@ -259,28 +265,40 @@ class MyTool(Tool):
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, Mapping) and val and _is_subagent_status(next(iter(val.values()))):
task_statuses = getattr(val, "_task_statuses", None)
if isinstance(task_statuses, dict):
return MyTool._format_value(task_statuses, key)
if isinstance(val, Mapping):
mapping = cast(Mapping[object, object], val)
else:
mapping = None
if (
mapping
and _is_subagent_status(next(iter(mapping.values())))
):
status_mapping: Mapping[object, SubagentStatus] = cast(Any, mapping)
prefix = f"{key}: " if key else ""
lines = [f"{prefix}{len(val)} subagent(s):"]
for tid, st in val.items():
lines = [f"{prefix}{len(status_mapping)} subagent(s):"]
for tid, st in status_mapping.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}"
dynamic_value = cast(Any, val)
if hasattr(dynamic_value, "tool_names"):
tool_names: Any = getattr(dynamic_value, "tool_names")
return f"tools: {len(tool_names)} registered — {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
# Mapping — small: show content; large: show keys for dot-path navigation
if isinstance(val, Mapping):
ks = list(val.keys())
value_mapping = cast(Mapping[object, object], val)
ks = list(value_mapping.keys())
if not ks:
return f"{key}: {{}}" if key else "{}"
if len(ks) <= 5:
r = repr(val)
r = repr(value_mapping)
if len(r) <= 200:
return f"{key}: {r}" if key else r
preview = ", ".join(str(k) for k in ks[:15])
@@ -288,18 +306,20 @@ class MyTool(Tool):
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)
sequence = cast(list[object] | tuple[object, ...], val)
if len(sequence) > 20:
return f"{key}: [{len(sequence)} items]" if key else f"[{len(sequence)} items]"
r = repr(sequence)
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())
value_type = type(cast(object, val))
cls_name = value_type.__name__
model_fields = cast(object, getattr(value_type, "model_fields", None))
if isinstance(model_fields, Mapping) and model_fields:
fields = list(cast(Mapping[str, object], model_fields).keys())
if len(fields) <= 8:
# Small config objects: show field=value pairs
pairs = []
pairs: list[str] = []
for f in fields:
fv = getattr(val, f, "?")
if MyTool._is_sensitive_field_name(f):
@@ -311,7 +331,8 @@ class MyTool(Tool):
preview = ", ".join(pairs)
return f"{key}: {preview}" if key else preview
else:
fields = [a for a in getattr(val, "__dict__", {}) if not a.startswith("__")]
attributes = cast(dict[str, Any], getattr(val, "__dict__", {}))
fields = [name for name in attributes if not name.startswith("__")]
if fields:
preview = ", ".join(str(f) for f in fields[:20])
suffix = ", ..." if len(fields) > 20 else ""
@@ -417,6 +438,7 @@ class MyTool(Tool):
def _modify(self, key: str | None, value: Any) -> str:
if err := self._validate_key(key):
return err
key = cast(str, key)
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}")
@@ -478,7 +500,7 @@ class MyTool(Tool):
def _modify_restricted(self, key: str, value: Any) -> str:
spec = self.RESTRICTED[key]
expected = spec["type"]
expected = cast(type[Any], spec["type"])
if expected is int and isinstance(value, bool):
return ToolResult.error(f"Error: '{key}' must be {expected.__name__}, got bool")
if not isinstance(value, expected):
@@ -499,9 +521,9 @@ class MyTool(Tool):
"during an active session; use a configured model_preset"
)
if key == "model":
self._runtime_state.set_runtime_model(value)
self._runtime_state.set_runtime_model(cast(str, value))
elif key == "context_window_tokens":
self._runtime_state.set_runtime_context_window(value)
self._runtime_state.set_runtime_context_window(cast(int, value))
else:
setattr(self._runtime_state, key, value)
if key == "max_iterations" and hasattr(
@@ -516,7 +538,8 @@ class MyTool(Tool):
if _has_real_attr(self._runtime_state, key):
old = getattr(self._runtime_state, key)
if isinstance(old, (str, int, float, bool)):
old_t, new_t = type(old), type(value)
old_t: type[Any] = type(old)
new_t = cast(type[Any], type(value))
if old_t is float and new_t is int:
pass # int → float coercion allowed
elif old_t is not new_t:
@@ -555,12 +578,12 @@ class MyTool(Tool):
if isinstance(value, (str, int, float, bool, type(None))):
return None
if isinstance(value, list):
for i, item in enumerate(value):
for i, item in enumerate(cast(list[Any], 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():
for k, v in cast(dict[Any, Any], 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):
+20 -10
View File
@@ -18,13 +18,14 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import current_request_session_key
from nanobot.agent.tools.context import ToolContext, current_request_session_key
from nanobot.agent.tools.exec_session import (
DEFAULT_EXEC_SESSION_MANAGER,
DEFAULT_MAX_OUTPUT_CHARS,
DEFAULT_YIELD_MS,
MAX_OUTPUT_CHARS,
MAX_YIELD_MS,
ExecSessionManager,
clamp_session_int,
format_session_poll,
)
@@ -174,11 +175,11 @@ class ExecTool(Tool):
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.exec.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
cfg = ctx.config.exec
return cls(
working_dir=ctx.workspace,
@@ -193,7 +194,7 @@ class ExecTool(Tool):
allowed_env_keys=cfg.allowed_env_keys,
allow_patterns=cfg.allow_patterns,
deny_patterns=cfg.deny_patterns,
session_manager=getattr(ctx, "exec_session_manager", None),
session_manager=ctx.exec_session_manager,
)
def __init__(
@@ -211,7 +212,7 @@ class ExecTool(Tool):
sandbox_ro_binds: list[str] | None = None,
sandbox_rw_binds: list[str] | None = None,
allowed_env_keys: list[str] | None = None,
session_manager: Any | None = None,
session_manager: ExecSessionManager | None = None,
):
self.timeout = timeout
self.working_dir = working_dir
@@ -344,7 +345,7 @@ class ExecTool(Tool):
# misses it, leaving a zombie.
_reap_pid(process.pid)
output_parts = []
output_parts: list[str] = []
if stdout:
output_parts.append(stdout.decode("utf-8", errors="replace"))
@@ -504,7 +505,7 @@ class ExecTool(Tool):
)
def _compose_path(self, current_path: str) -> str:
parts = []
parts: list[str] = []
if self.path_prepend:
parts.append(self.path_prepend)
if current_path:
@@ -514,7 +515,7 @@ class ExecTool(Tool):
return os.pathsep.join(parts)
def _wrap_path_export(self, command: str, env: dict[str, str]) -> str:
segments = []
segments: list[str] = []
if self.path_prepend:
env["NANOBOT_PATH_PREPEND"] = self.path_prepend
segments.append("$NANOBOT_PATH_PREPEND")
@@ -568,11 +569,21 @@ class ExecTool(Tool):
env=env,
)
shell_program = shell_program or shutil.which("bash") or "/bin/bash"
args = [shell_program]
args: list[str] = [shell_program]
shell_name = Path(shell_program).name.lower()
if login and shell_name in {"bash", "bash.exe", "zsh", "zsh.exe"}:
args.append("-l")
args.extend(["-c", command])
if process_tree:
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
start_new_session=True,
)
return await asyncio.create_subprocess_exec(
*args,
stdin=stdin,
@@ -580,7 +591,6 @@ class ExecTool(Tool):
stderr=asyncio.subprocess.PIPE,
cwd=cwd,
env=env,
**({"start_new_session": True} if process_tree else {}),
)
@staticmethod
+8 -2
View File
@@ -1,5 +1,7 @@
"""Spawn tool for creating background subagents."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
@@ -16,6 +18,7 @@ from nanobot.security.workspace_access import current_workspace_scope
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.context import ToolContext
@tool_parameters(
@@ -49,8 +52,11 @@ class SpawnTool(Tool):
self._manager = manager
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls(manager=ctx.subagent_manager)
def create(cls, ctx: ToolContext) -> Tool:
manager = ctx.subagent_manager
if manager is None:
raise RuntimeError("SpawnTool requires an initialized subagent manager")
return cls(manager=manager)
@property
def name(self) -> str:
+100 -55
View File
@@ -1,5 +1,7 @@
"""Web tools: web_search and web_fetch."""
# pyright: reportIncompatibleMethodOverride=false
from __future__ import annotations
import asyncio
@@ -7,7 +9,8 @@ import html
import json
import os
import re
from typing import Any, Callable
from collections.abc import Callable
from typing import Any, cast
from urllib.parse import quote, urljoin, urlparse
import httpx
@@ -15,6 +18,7 @@ from loguru import logger
from pydantic import Field
from nanobot.agent.tools.base import Tool, ToolResult, tool_parameters
from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.schema import (
BooleanSchema,
IntegerSchema,
@@ -291,8 +295,8 @@ class WebSearchTool(Tool):
"""Search the web using configured provider."""
_scopes = {"core", "subagent"}
name = "web_search"
description = (
name = "web_search" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Search the web. Returns titles, URLs, and snippets. "
"count defaults to 5 (max 10). "
"Some providers support timeRange, authLevel, and queryRewrite. "
@@ -302,20 +306,21 @@ class WebSearchTool(Tool):
config_key = "web"
@classmethod
def config_cls(cls):
def config_cls(cls) -> type[WebToolsConfig]:
return WebToolsConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.web.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
config_loader = None
def create(cls, ctx: ToolContext) -> Tool:
config_loader: Callable[[], WebSearchConfig] | None = None
if ctx.provider_snapshot_loader is not None:
def config_loader():
def _load_search_config() -> WebSearchConfig:
from nanobot.config.loader import load_config, resolve_config_env_vars
return resolve_config_env_vars(load_config()).tools.web.search
config_loader = _load_search_config
return cls(
config=ctx.config.web.search,
proxy=ctx.config.web.proxy,
@@ -404,7 +409,7 @@ class WebSearchTool(Tool):
auth_level: int | None = None,
query_rewrite: bool | None = None,
**kwargs: Any,
) -> str:
) -> str: # pyright: ignore[reportIncompatibleMethodOverride]
self._refresh_config()
provider = self.config.provider.strip().lower() or "brave"
n = min(max(count or self.config.max_results, 1), 10)
@@ -448,15 +453,20 @@ class WebSearchTool(Tool):
async def _search_olostep(self, query: str, n: int) -> str:
try:
from olostep import AsyncOlostep, Olostep_BaseError
from olostep import ( # pyright: ignore[reportMissingImports]
AsyncOlostep, # pyright: ignore[reportUnknownVariableType]
Olostep_BaseError, # pyright: ignore[reportUnknownVariableType]
)
except ImportError:
return ToolResult.error("Error: olostep package not installed. Run: pip install olostep")
async_olostep = cast(Any, AsyncOlostep)
olostep_base_error = cast(type[Exception], Olostep_BaseError)
api_key = self.config.api_key or os.environ.get("OLOSTEP_API_KEY", "")
if not api_key:
logger.warning("OLOSTEP_API_KEY not set, falling back to DuckDuckGo")
return await self._search_duckduckgo(query, n)
try:
async with AsyncOlostep(api_key=api_key) as client:
async with async_olostep(api_key=api_key) as client:
if self.proxy:
transport = getattr(client, "_transport", None)
http_client = getattr(transport, "_client", None)
@@ -472,14 +482,16 @@ class WebSearchTool(Tool):
),
http2=True,
)
result = await client.answers.create(task=query)
result: Any = await client.answers.create(task=query)
sources = getattr(result, "sources", None) or []
source_lines = []
for i, source in enumerate(sources[:n], 1):
sources = cast(list[Any], getattr(result, "sources", None) or [])
source_lines: list[str] = []
for i, source_value in enumerate(sources[:n], 1):
source: Any = source_value
if isinstance(source, dict):
title = source.get("title", "")
url = source.get("url", "")
source_dict = cast(dict[str, Any], source)
title = source_dict.get("title", "")
url = source_dict.get("url", "")
else:
title = getattr(source, "title", "")
url = getattr(source, "url", "")
@@ -493,7 +505,7 @@ class WebSearchTool(Tool):
answer_text = getattr(result, "answer", "") or ""
items = [{"title": answer_text or "Olostep answer", "url": "", "content": "\n".join(source_lines)}]
return _format_results(query, items, n)
except Olostep_BaseError as e:
except olostep_base_error as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
except Exception as e:
return ToolResult.error(f"Error: Olostep search error: {type(e).__name__}: {e}")
@@ -510,6 +522,7 @@ class WebSearchTool(Tool):
"User-Agent": self.user_agent,
}
async with httpx.AsyncClient(proxy=self.proxy) as client:
r: httpx.Response | None = None
for attempt in range(2):
r = await client.get(
"https://api.search.brave.com/res/v1/web/search",
@@ -522,6 +535,7 @@ class WebSearchTool(Tool):
if attempt == 0:
logger.warning("Brave search rate limited; retrying once in 1.0s")
await asyncio.sleep(1.0)
assert r is not None
r.raise_for_status()
items = [
{"title": x.get("title", ""), "url": x.get("url", ""), "content": x.get("description", "")}
@@ -691,13 +705,19 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
items = []
for result in r.json().get("results", []):
if not isinstance(result, dict):
data = cast(dict[str, Any], r.json())
items: list[dict[str, Any]] = []
for result_value in cast(list[object], data.get("results", [])):
if not isinstance(result_value, dict):
continue
highlights = result.get("highlights") or []
result = cast(dict[str, Any], result_value)
highlights: Any = result.get("highlights") or []
if isinstance(highlights, list):
content = "\n".join(str(highlight) for highlight in highlights if highlight)
content = "\n".join(
str(highlight)
for highlight in cast(list[object], highlights)
if highlight
)
else:
content = str(highlights)
if not content:
@@ -737,14 +757,17 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
items = [
data = cast(dict[str, Any], r.json())
organic = cast(list[object], data.get("organic", []))
items: list[dict[str, Any]] = [
{
"title": result.get("title", ""),
"url": result.get("link", ""),
"content": result.get("snippet", ""),
}
for result in r.json().get("organic", [])
if isinstance(result, dict)
for result_value in organic
if isinstance(result_value, dict)
for result in (cast(dict[str, Any], result_value),)
]
return _format_results(query, items, n)
except httpx.HTTPStatusError as e:
@@ -806,7 +829,7 @@ class WebSearchTool(Tool):
timeout=float(self.config.timeout),
)
r.raise_for_status()
data = r.json()
data = cast(dict[str, Any], r.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
return ToolResult.error("Error: Volcengine search rate limited. Try again later or reduce search frequency.")
@@ -814,20 +837,36 @@ class WebSearchTool(Tool):
except Exception as e:
return ToolResult.error(f"Error: Volcengine search failed: {e}")
error = (data.get("ResponseMetadata") or {}).get("Error") or data.get("Error") or data.get("error")
response_metadata = cast(
dict[str, Any],
data.get("ResponseMetadata") or {},
)
error = (
response_metadata.get("Error")
or data.get("Error")
or data.get("error")
)
if error:
if isinstance(error, dict):
error = cast(dict[str, Any], error)
code = error.get("Code") or error.get("code") or "unknown"
message = error.get("Message") or error.get("message") or error
return ToolResult.error(f"Error: Volcengine search error {code}: {message}")
return ToolResult.error(f"Error: Volcengine search error: {error}")
result = data.get("Result") or data
web_results = result.get("WebResults") or result.get("webResults") or result.get("results") or []
result = cast(dict[str, Any], data.get("Result") or data)
web_results = cast(
list[object],
result.get("WebResults")
or result.get("webResults")
or result.get("results")
or [],
)
items: list[dict[str, Any]] = []
for item in web_results:
if not isinstance(item, dict):
for item_value in web_results:
if not isinstance(item_value, dict):
continue
item = cast(dict[str, Any], item_value)
meta_parts = [
str(part)
for part in (
@@ -837,7 +876,7 @@ class WebSearchTool(Tool):
)
if part
]
summary = (
summary = cast(str, (
item.get("Summary")
or item.get("summary")
or item.get("Snippet")
@@ -845,7 +884,7 @@ class WebSearchTool(Tool):
or item.get("Content")
or item.get("content")
or ""
)
))
content = "\n".join(part for part in (" | ".join(meta_parts), summary) if part)
items.append(
{
@@ -861,18 +900,20 @@ class WebSearchTool(Tool):
try:
# Note: duckduckgo_search is synchronous and does its own requests
# We run it in a thread to avoid blocking the loop
from ddgs import DDGS
from ddgs import DDGS # pyright: ignore[reportUnknownVariableType]
ddgs = DDGS(timeout=10, proxy=self.proxy)
ddgs_type = cast(Any, DDGS)
ddgs = ddgs_type(timeout=10, proxy=self.proxy)
raw = await asyncio.wait_for(
asyncio.to_thread(ddgs.text, query, max_results=n),
timeout=self.config.timeout,
)
if not raw:
return f"No results for: {query}"
items = [
raw_items = cast(list[dict[str, Any]], raw)
items: list[dict[str, Any]] = [
{"title": r.get("title", ""), "url": r.get("href", ""), "content": r.get("body", "")}
for r in raw
for r in raw_items
]
return _format_results(query, items, n)
except Exception as e:
@@ -907,15 +948,19 @@ class WebSearchTool(Tool):
if r.status_code == 429:
return ToolResult.error("Error: Bocha search rate-limited (HTTP 429). Wait and retry.")
r.raise_for_status()
data = r.json()
wrapped_data = data.get("data") if isinstance(data, dict) else None
result_data = wrapped_data if isinstance(wrapped_data, dict) else data
web_pages = (
result_data.get("webPages", {}).get("value", [])
if isinstance(result_data, dict)
else []
data = cast(dict[str, Any], r.json())
wrapped_data = data.get("data")
result_data = (
cast(dict[str, Any], wrapped_data)
if isinstance(wrapped_data, dict)
else data
)
items = [
web_pages_data = cast(
dict[str, Any],
result_data.get("webPages", {}),
)
web_pages = cast(list[dict[str, Any]], web_pages_data.get("value", []))
items: list[dict[str, Any]] = [
{
"title": x.get("name", ""),
"url": x.get("url", ""),
@@ -946,8 +991,8 @@ class WebFetchTool(Tool):
"""Fetch and extract content from a URL."""
_scopes = {"core", "subagent"}
name = "web_fetch"
description = (
name = "web_fetch" # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
description = ( # pyright: ignore[reportIncompatibleMethodOverride, reportAssignmentType]
"Fetch a URL and extract readable content (HTML → markdown/text). "
"Output is capped at maxChars (default 50 000). "
"Works for most web pages and docs; may fail on login-walled or JS-heavy sites."
@@ -956,15 +1001,15 @@ class WebFetchTool(Tool):
config_key = "web"
@classmethod
def config_cls(cls):
def config_cls(cls) -> type[WebToolsConfig]:
return WebToolsConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
def enabled(cls, ctx: ToolContext) -> bool:
return ctx.config.web.enable
@classmethod
def create(cls, ctx: Any) -> Tool:
def create(cls, ctx: ToolContext) -> Tool:
return cls(
config=ctx.config.web.fetch,
proxy=ctx.config.web.proxy,
@@ -987,10 +1032,10 @@ class WebFetchTool(Tool):
extract_mode: str = "markdown",
max_chars: int | None = None,
**kwargs: Any,
) -> Any:
) -> Any: # pyright: ignore[reportIncompatibleMethodOverride]
url = url.strip(" \t\r\n`\"'")
extract_mode = kwargs.pop("extractMode", extract_mode)
max_chars = kwargs.pop("maxChars", max_chars) or self.max_chars
max_chars = cast(int, kwargs.pop("maxChars", max_chars) or self.max_chars)
is_valid, error_msg = _validate_url_safe(url)
if not is_valid:
return json.dumps({"error": f"URL validation failed: {error_msg}", "url": url}, ensure_ascii=False)
@@ -1119,10 +1164,10 @@ class WebFetchTool(Tool):
return json.dumps({"error": str(e), "url": url}, ensure_ascii=False)
def _extract_readable_html(self, html_content: str, extract_mode: str) -> str:
from readability import Document
from readability import Document # pyright: ignore[reportMissingTypeStubs]
doc = Document(html_content)
summary = doc.summary()
summary = cast(str, doc.summary())
content = self._to_markdown(summary) if extract_mode == "markdown" else _strip_tags(summary)
return f"# {doc.title()}\n\n{content}" if doc.title() else content
+6 -3
View File
@@ -6,7 +6,7 @@ import dataclasses
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import Any
from typing import TYPE_CHECKING, Any, cast
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.outbound_events import (
@@ -20,6 +20,9 @@ from nanobot.bus.progress import build_bus_progress_callback
from nanobot.bus.queue import MessageBus
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
if TYPE_CHECKING:
from nanobot.utils.llm_runtime import LLMRuntime
@dataclass(frozen=True)
class TurnRoute:
@@ -62,7 +65,7 @@ class TurnDeliveryFactory:
route = self._default_route(msg, session_key)
if self.route_policy is not None:
route = self.route_policy(msg, session_key, route)
if not isinstance(route, TurnRoute):
if not isinstance(cast(object, route), TurnRoute):
raise TypeError("turn route policy must return TurnRoute")
return TurnDelivery(
bus=self.bus,
@@ -186,7 +189,7 @@ class TurnDelivery:
started_at=started_at,
)
def record_runtime(self, runtime: Any) -> None:
def record_runtime(self, runtime: LLMRuntime) -> None:
self.runtime_event_publisher.record_turn_runtime(self.session_key, runtime)
def record_latency(self, latency_ms: int | None) -> None: