feat: as_anthropic_tools — Anthropic tool-runner export, both modes

Fills the last cell of the agent-connection matrix: users driving their
own anthropic tool_runner loop get runnable tools directly. Cloud wraps
the live MCP tool set with input schemas passing through verbatim (MCP
inputSchema is the Messages API schema shape); local exposes the same
set messages() runs internally. The beta_tool wrapping moves from
local_chat into integrations/anthropic_sdk.py, parallel to
openai_agents.py, and messages() now consumes the shared builder.
agent_tools grows _bridge_invoker/_read_only_tools so the plain-function
and beta_tool cloud paths share invocation containment and the
read-only gate.
This commit is contained in:
Ray
2026-08-12 03:03:08 +08:00
parent daac9d2dc0
commit 4590dd855c
6 changed files with 192 additions and 43 deletions
+34 -23
View File
@@ -1220,18 +1220,11 @@ def _annotation_for(spec: dict) -> Any:
return _SCHEMA_TYPE_MAP.get(schema_type, Any)
def _make_bridge_function(bridge, meta: dict) -> Callable[..., str]:
"""One plain function for a cloud tool: real signature and docstring from
the server's schema, invocation proxied over MCP, errors contained."""
import keyword
name = str(meta.get("name") or "")
schema = meta.get("inputSchema") or {}
properties: dict[str, Any] = schema.get("properties") or {}
required = set(schema.get("required") or [])
def _bridge_invoker(bridge, name: str) -> Callable[[dict], str]:
"""One cloud tool call proxied over MCP: None-valued arguments are
dropped (None ≡ omitted, matching the contract's "omit if ..."
semantics) and failures are contained in the error envelope."""
def _invoke(arguments: dict[str, Any]) -> str:
# None ≡ omitted, matching the contract's "omit if ..." semantics.
arguments = {key: value for key, value in arguments.items()
if value is not None}
try:
@@ -1246,6 +1239,19 @@ def _make_bridge_function(bridge, meta: dict) -> Callable[..., str]:
"INTERNAL_ERROR",
)
return _dumps(payload)
return _invoke
def _make_bridge_function(bridge, meta: dict) -> Callable[..., str]:
"""One plain function for a cloud tool: real signature and docstring from
the server's schema, invocation proxied over MCP, errors contained."""
import keyword
name = str(meta.get("name") or "")
schema = meta.get("inputSchema") or {}
properties: dict[str, Any] = schema.get("properties") or {}
required = set(schema.get("required") or [])
_invoke = _bridge_invoker(bridge, name)
params_usable = all(param.isidentifier() and not keyword.iskeyword(param)
and param != "_invoke"
@@ -1300,22 +1306,27 @@ def _cloud_bridge(client):
return bridge
def _read_only_tools(tools_meta: list[dict]) -> list[dict]:
"""The management gate for consumers without a framework permission
layer: only tools the server marks read-only, guarded against a server
annotation regression silently disabling every tool."""
filtered = [meta for meta in tools_meta
if (meta.get("annotations") or {}).get("readOnlyHint") is True]
if tools_meta and not filtered:
raise PageIndexAPIError(
"The MCP server returned tools but none are annotated "
"read-only — a server annotation regression would otherwise "
"silently disable every tool. Pass include_management=True "
"to expose the unfiltered list."
)
return filtered
def _build_cloud_agent_tools(client, include_management: bool) -> list[Callable[..., str]]:
bridge = _cloud_bridge(client)
tools_meta = bridge.list_tools()
if not include_management:
# Plain functions have no framework permission layer, so the
# management gate lives here: only tools the server marks read-only.
filtered = [meta for meta in tools_meta
if (meta.get("annotations") or {}).get("readOnlyHint") is True]
if tools_meta and not filtered:
raise PageIndexAPIError(
"The MCP server returned tools but none are annotated "
"read-only — a server annotation regression would otherwise "
"silently disable every tool. Pass include_management=True "
"to expose the unfiltered list."
)
tools_meta = filtered
tools_meta = _read_only_tools(tools_meta)
return [_make_bridge_function(bridge, meta) for meta in tools_meta]
+27
View File
@@ -618,6 +618,33 @@ class PageIndexClient:
from .integrations.openai_agents import build_openai_tools
return build_openai_tools(self, include_management, hosted)
def as_anthropic_tools(self, include_management: bool = False) -> list:
"""
Runnable tools for the Anthropic SDK's tool runner — pass to
``client.beta.messages.tool_runner(tools=...)``.
Cloud: the full live read tool set (search, folders, images — as
enabled for your key), discovered from the PageIndex MCP server
and executed from your process; the server's input schemas pass
through verbatim (MCP and the Messages API share the schema
shape). The Messages API's MCP connector (``mcp_servers=``
pointing at ``{BASE_URL}/mcp``) is the server-side alternative
with no client-side tools involved. Local: the in-process tools —
the same set ``messages()`` runs internally.
Requires ``anthropic>=0.68.0``
(``pip install 'pageindex[anthropic]'``), imported only when this
method is called.
Args:
include_management (bool): Also expose tools that modify the
library. Local: adds ``remove_document``. Cloud: by default
only tools the server marks read-only are exposed; True
exposes the server's complete list (upload, delete, ...).
"""
from .integrations.anthropic_sdk import build_anthropic_tools
return build_anthropic_tools(self, include_management)
def as_claude_mcp(self, include_management: bool = False):
"""
``mcp_servers`` entry for the Claude Agent SDK.
+60
View File
@@ -0,0 +1,60 @@
"""Anthropic SDK adapter for the tool runner's tools=... slot.
Cloud clients get one runnable tool per live cloud MCP tool — the server's
input schemas pass through verbatim (MCP inputSchema and Messages API
input_schema are the same shape), calls proxied over MCP. Local clients get
the in-process tools — the same set messages() runs internally.
"""
from __future__ import annotations
from typing import Any
from ..errors import PageIndexAPIError
def build_anthropic_tools(client, include_management: bool = False) -> list:
try:
from anthropic import beta_tool
except ImportError as exc:
raise PageIndexAPIError(
"as_anthropic_tools requires the Anthropic SDK tool runner "
"(anthropic>=0.68.0) — pip install -U anthropic (or pip install "
"'pageindex[anthropic]')."
) from exc
if getattr(client, "api_key", None):
from ..agent_tools import (_bridge_invoker, _cloud_bridge,
_read_only_tools)
bridge = _cloud_bridge(client)
tools_meta = bridge.list_tools()
if not include_management:
tools_meta = _read_only_tools(tools_meta)
def make_cloud(meta: dict):
name = str(meta.get("name") or "tool")
invoke = _bridge_invoker(bridge, name)
def _fn(**kwargs: Any) -> str:
return invoke(kwargs)
_fn.__name__ = name
return beta_tool(
_fn, name=name, description=meta.get("description", ""),
input_schema=meta.get("inputSchema")
or {"type": "object", "properties": {}},
)
return [make_cloud(meta) for meta in tools_meta]
from ..agent_tools import (_local_description, _local_schema, call_tool,
tool_names)
def make_local(name: str):
def _fn(**kwargs: Any) -> str:
return call_tool(client, name, kwargs)[0]
_fn.__name__ = name
return beta_tool(_fn, name=name, description=_local_description(name),
input_schema=_local_schema(name))
return [make_local(name) for name in tool_names(include_management)]
+4 -18
View File
@@ -26,9 +26,7 @@ import time
import uuid
from typing import Any, Iterator, Optional, Union
from .agent_tools import (AGENT_INSTRUCTIONS, _local_description,
_local_schema, call_tool, doc_targeting_block,
tool_names)
from .agent_tools import AGENT_INSTRUCTIONS, doc_targeting_block
from .errors import PageIndexAPIError
CHAT_HEADER = (
@@ -506,20 +504,6 @@ def _anthropic_client():
return anthropic.Anthropic()
def _runnable_tools(client) -> list:
from anthropic import beta_tool
def make(name: str):
def _fn(**kwargs: Any) -> str:
return call_tool(client, name, kwargs)[0]
_fn.__name__ = name
return beta_tool(_fn, name=name, description=_local_description(name),
input_schema=_local_schema(name))
return [make(name) for name in tool_names()]
def _anthropic_system(extra_system, block: Optional[str]) -> list[dict]:
"""System blocks: cache_control marks the stable managed prefix only
(the API allows 4 breakpoints total — the varying doc block and caller
@@ -580,6 +564,8 @@ def run_messages(client, messages, model: str, max_tokens: int,
stop_sequences: Optional[list[str]] = None,
max_turns: Optional[int] = None,
) -> Union[dict, Iterator[Any]]:
from .integrations.anthropic_sdk import build_anthropic_tools
_require_anthropic()
_validate_max_turns(max_turns)
if (not isinstance(messages, list) or not messages
@@ -596,7 +582,7 @@ def run_messages(client, messages, model: str, max_tokens: int,
max_tokens=max_tokens,
messages=prepared,
model=model,
tools=_runnable_tools(client),
tools=build_anthropic_tools(client),
system=_anthropic_system(system, block),
stream=stream,
# Bounded like the OpenAI surfaces (their framework default is 10).
+3 -2
View File
@@ -42,8 +42,9 @@ claude-agent-sdk = { version = ">=0.1.0", optional = true }
# 0.8.0 offloads sync tools to a thread; older versions run them inline and
# a blocking bridge call would freeze the agent event loop.
openai-agents = { version = ">=0.8.0", optional = true }
# messages() drives the SDK's beta tool runner; 0.68.0 is the first release
# with tool_runner(stream/system/max_iterations) and beta_tool(input_schema).
# messages() and as_anthropic_tools() need the SDK's beta tool runner;
# 0.68.0 is the first release with tool_runner(stream/system/max_iterations)
# and beta_tool(input_schema).
anthropic = { version = ">=0.68.0", optional = true }
[tool.poetry.extras]
+64
View File
@@ -484,9 +484,73 @@ def test_as_claude_mcp_local_when_installed(client):
assert server.get("type") != "http"
def test_as_anthropic_tools_missing_dependency(client, monkeypatch):
monkeypatch.setitem(sys.modules, "anthropic", None)
with pytest.raises(PageIndexAPIError, match="anthropic"):
client.as_anthropic_tools()
def test_as_anthropic_tools_local_in_process(client, store_path):
pytest.importorskip("anthropic")
from pageindex.agent_tools import _local_description, _local_schema
tools = client.as_anthropic_tools()
assert [tool.name for tool in tools] == list(tool_names())
browse = {tool.name: tool for tool in tools}["browse_documents"]
assert browse.input_schema == _local_schema("browse_documents")
assert browse.description == _local_description("browse_documents")
seed_doc(store_path, "pi-a", "report.pdf")
assert "report.pdf" in browse.call({})
def test_as_anthropic_tools_local_management_opt_in(client):
pytest.importorskip("anthropic")
names = [tool.name
for tool in client.as_anthropic_tools(include_management=True)]
assert names == list(tool_names(include_management=True))
assert "remove_document" in names
def test_as_anthropic_tools_cloud_schemas_pass_through(cloud_with_fake_bridge):
pytest.importorskip("anthropic")
cloud, created = cloud_with_fake_bridge
tools = cloud.as_anthropic_tools()
assert [tool.name for tool in tools] == ["search_documents", "get_document"]
bridge = created["bridge"]
assert tools[0].input_schema == bridge.tools[0]["inputSchema"]
assert tools[0].description == bridge.tools[0]["description"]
# Calls route over the bridge; None-valued arguments mean "omitted".
out = tools[1].call({"doc_name": "x.pdf", "folder_id": None})
assert bridge.calls == [("get_document", {"doc_name": "x.pdf"})]
assert json.loads(out)["success"] is True
def test_as_anthropic_tools_cloud_management_opt_in(cloud_with_fake_bridge):
pytest.importorskip("anthropic")
cloud, _ = cloud_with_fake_bridge
names = [tool.name
for tool in cloud.as_anthropic_tools(include_management=True)]
assert names == ["search_documents", "get_document",
"remove_document", "unannotated_tool"]
def test_as_anthropic_tools_cloud_contains_bridge_errors(cloud_with_fake_bridge):
pytest.importorskip("anthropic")
cloud, created = cloud_with_fake_bridge
tools = cloud.as_anthropic_tools()
def boom(name, arguments):
raise RuntimeError("bridge down")
created["bridge"].call_tool = boom
payload = json.loads(tools[0].call({"query": "q"}))
assert payload["errorCode"] == "INTERNAL_ERROR"
assert "bridge down" in payload["error"]
def test_agent_tools_work_without_frameworks(client, store_path, monkeypatch):
monkeypatch.setitem(sys.modules, "agents", None)
monkeypatch.setitem(sys.modules, "claude_agent_sdk", None)
monkeypatch.setitem(sys.modules, "anthropic", None)
seed_doc(store_path, "pi-a", "report.pdf")
browse = client.agent_tools()[0]
assert "report.pdf" in browse()