e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
155 lines
6.0 KiB
Python
155 lines
6.0 KiB
Python
"""
|
|
Deferred tool loading (progressive disclosure for tool schemas).
|
|
|
|
Tools flagged ``BaseTool.deferred`` (all MCP tools, by default) are NOT part
|
|
of the initial per-turn tool list. The system prompt carries a one-line
|
|
manifest per deferred tool (:func:`render_deferred_tools_manifest`); when the
|
|
model decides it needs one, it calls the ``load_tools`` builtin with exact
|
|
names and the :class:`DeferredToolLoader` appends the full schemas to the
|
|
live ``tool_schemas`` list — ``run_agentic_loop`` re-reads that list every
|
|
iteration, so the tools become callable immediately. Loaded names persist
|
|
per chat session so later turns include those schemas from the start.
|
|
|
|
This keeps the always-on schema surface small, which measurably improves
|
|
tool selection on weaker models, while keeping every connected tool one
|
|
cheap call away.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from deeptutor.core.tool_protocol import BaseTool
|
|
from deeptutor.runtime.registry.tool_registry import ToolRegistry
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def render_deferred_tools_manifest(tools: list[BaseTool], *, language: str = "en") -> str:
|
|
"""System-prompt block listing deferred tools, grouped by MCP server."""
|
|
if not tools:
|
|
return ""
|
|
zh = (language or "en").lower().startswith("zh")
|
|
groups: dict[str, list[tuple[str, str]]] = {}
|
|
for tool in tools:
|
|
definition = tool.get_definition()
|
|
group = getattr(tool, "server_name", "") or "other"
|
|
groups.setdefault(group, []).append((definition.name, definition.description))
|
|
if zh:
|
|
lines: list[str] = [
|
|
"## 扩展工具",
|
|
"这些工具存在,但尚未加载;直接调用会失败。要使用其中任意工具,"
|
|
"请先用准确的工具名称调用 `load_tools`,随后这些 schema 会在本会话中保持可用。",
|
|
"",
|
|
]
|
|
else:
|
|
lines = [
|
|
"## Extended Tools",
|
|
"These tools exist but are NOT loaded yet; calling one directly "
|
|
"will fail. To use any of them, first call `load_tools` with the "
|
|
"exact tool names; their schemas then stay available for the rest "
|
|
"of the session.",
|
|
"",
|
|
]
|
|
for group in sorted(groups):
|
|
if group == "other":
|
|
header = "### 其他" if zh else "### Other"
|
|
else:
|
|
header = f"### MCP 服务器:{group}" if zh else f"### MCP server: {group}"
|
|
lines.append(header)
|
|
for name, description in sorted(groups[group]):
|
|
lines.append(f"- **{name}** - {description}")
|
|
lines.append("")
|
|
return "\n".join(lines).rstrip()
|
|
|
|
|
|
class DeferredToolLoader:
|
|
"""Per-turn handle that loads deferred tool schemas into the live list.
|
|
|
|
Created by the chat pipeline once per turn and injected into
|
|
``load_tools`` calls server-side (the LLM never sees the handle).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
registry: ToolRegistry,
|
|
session_id: str,
|
|
loaded: set[str],
|
|
allowed: set[str] | None = None,
|
|
) -> None:
|
|
self._registry = registry
|
|
self._session_id = session_id
|
|
self._loaded = set(loaded)
|
|
# ``None`` = every deferred tool is loadable; a set restricts the
|
|
# loadable pool (e.g. a partner's configured MCP tool whitelist).
|
|
# Enforced here, not only at manifest time, so the model cannot load
|
|
# an off-list tool by guessing its name.
|
|
self._allowed = set(allowed) if allowed is not None else None
|
|
self._live_schemas: list[dict[str, Any]] | None = None
|
|
|
|
def _is_allowed(self, name: str) -> bool:
|
|
return self._allowed is None or name in self._allowed
|
|
|
|
@property
|
|
def loaded_names(self) -> set[str]:
|
|
return set(self._loaded)
|
|
|
|
def bind_live_schemas(self, schemas: list[dict[str, Any]]) -> None:
|
|
"""Attach the turn's live ``tool_schemas`` list (mutated in place)."""
|
|
self._live_schemas = schemas
|
|
|
|
def initial_schemas(self) -> list[dict[str, Any]]:
|
|
"""Schemas for tools already loaded in this session (manifest-validated)."""
|
|
schemas: list[dict[str, Any]] = []
|
|
stale: set[str] = set()
|
|
for name in sorted(self._loaded):
|
|
tool = self._registry.get(name)
|
|
if tool is None or not getattr(tool, "deferred", False):
|
|
stale.add(name)
|
|
continue
|
|
if not self._is_allowed(name):
|
|
continue
|
|
schemas.append(tool.get_definition().to_openai_schema())
|
|
if stale:
|
|
# Server removed/renamed since last turn — drop quietly.
|
|
self._loaded -= stale
|
|
self._persist()
|
|
return schemas
|
|
|
|
def load(self, names: list[str]) -> dict[str, list[str]]:
|
|
"""Load the given deferred tools; returns name lists by outcome."""
|
|
loaded: list[str] = []
|
|
already: list[str] = []
|
|
unknown: list[str] = []
|
|
for raw in names:
|
|
name = str(raw or "").strip()
|
|
if not name:
|
|
continue
|
|
if name in self._loaded:
|
|
already.append(name)
|
|
continue
|
|
tool = self._registry.get(name)
|
|
if tool is None or not getattr(tool, "deferred", False) or not self._is_allowed(name):
|
|
unknown.append(name)
|
|
continue
|
|
if self._live_schemas is not None:
|
|
self._live_schemas.append(tool.get_definition().to_openai_schema())
|
|
self._loaded.add(name)
|
|
loaded.append(name)
|
|
if loaded:
|
|
self._persist()
|
|
return {"loaded": loaded, "already_loaded": already, "unknown": unknown}
|
|
|
|
def _persist(self) -> None:
|
|
try:
|
|
from deeptutor.services.mcp.session_state import record_loaded_tools
|
|
|
|
record_loaded_tools(self._session_id, self._loaded)
|
|
except Exception:
|
|
logger.warning("failed to persist deferred-tool state", exc_info=True)
|
|
|
|
|
|
__all__ = ["DeferredToolLoader", "render_deferred_tools_manifest"]
|