refactor: enforce BasedPyright strict type checking (#5158)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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] = []
|
||||
|
||||
@@ -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
@@ -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":
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Search tools: file discovery and grep."""
|
||||
|
||||
# pyright: reportIncompatibleMethodOverride=false, reportPrivateUsage=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
|
||||
+53
-30
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,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:
|
||||
|
||||
Reference in New Issue
Block a user