mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-10-02 07:44:37 +08:00
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:
+34
-23
@@ -1220,18 +1220,11 @@ def _annotation_for(spec: dict) -> Any:
|
|||||||
return _SCHEMA_TYPE_MAP.get(schema_type, Any)
|
return _SCHEMA_TYPE_MAP.get(schema_type, Any)
|
||||||
|
|
||||||
|
|
||||||
def _make_bridge_function(bridge, meta: dict) -> Callable[..., str]:
|
def _bridge_invoker(bridge, name: str) -> Callable[[dict], str]:
|
||||||
"""One plain function for a cloud tool: real signature and docstring from
|
"""One cloud tool call proxied over MCP: None-valued arguments are
|
||||||
the server's schema, invocation proxied over MCP, errors contained."""
|
dropped (None ≡ omitted, matching the contract's "omit if ..."
|
||||||
import keyword
|
semantics) and failures are contained in the error envelope."""
|
||||||
|
|
||||||
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 _invoke(arguments: dict[str, Any]) -> str:
|
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()
|
arguments = {key: value for key, value in arguments.items()
|
||||||
if value is not None}
|
if value is not None}
|
||||||
try:
|
try:
|
||||||
@@ -1246,6 +1239,19 @@ def _make_bridge_function(bridge, meta: dict) -> Callable[..., str]:
|
|||||||
"INTERNAL_ERROR",
|
"INTERNAL_ERROR",
|
||||||
)
|
)
|
||||||
return _dumps(payload)
|
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)
|
params_usable = all(param.isidentifier() and not keyword.iskeyword(param)
|
||||||
and param != "_invoke"
|
and param != "_invoke"
|
||||||
@@ -1300,22 +1306,27 @@ def _cloud_bridge(client):
|
|||||||
return bridge
|
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]]:
|
def _build_cloud_agent_tools(client, include_management: bool) -> list[Callable[..., str]]:
|
||||||
bridge = _cloud_bridge(client)
|
bridge = _cloud_bridge(client)
|
||||||
tools_meta = bridge.list_tools()
|
tools_meta = bridge.list_tools()
|
||||||
if not include_management:
|
if not include_management:
|
||||||
# Plain functions have no framework permission layer, so the
|
tools_meta = _read_only_tools(tools_meta)
|
||||||
# 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
|
|
||||||
return [_make_bridge_function(bridge, meta) for meta in tools_meta]
|
return [_make_bridge_function(bridge, meta) for meta in tools_meta]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -618,6 +618,33 @@ class PageIndexClient:
|
|||||||
from .integrations.openai_agents import build_openai_tools
|
from .integrations.openai_agents import build_openai_tools
|
||||||
return build_openai_tools(self, include_management, hosted)
|
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):
|
def as_claude_mcp(self, include_management: bool = False):
|
||||||
"""
|
"""
|
||||||
``mcp_servers`` entry for the Claude Agent SDK.
|
``mcp_servers`` entry for the Claude Agent SDK.
|
||||||
|
|||||||
@@ -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
@@ -26,9 +26,7 @@ import time
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Iterator, Optional, Union
|
from typing import Any, Iterator, Optional, Union
|
||||||
|
|
||||||
from .agent_tools import (AGENT_INSTRUCTIONS, _local_description,
|
from .agent_tools import AGENT_INSTRUCTIONS, doc_targeting_block
|
||||||
_local_schema, call_tool, doc_targeting_block,
|
|
||||||
tool_names)
|
|
||||||
from .errors import PageIndexAPIError
|
from .errors import PageIndexAPIError
|
||||||
|
|
||||||
CHAT_HEADER = (
|
CHAT_HEADER = (
|
||||||
@@ -506,20 +504,6 @@ def _anthropic_client():
|
|||||||
return anthropic.Anthropic()
|
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]:
|
def _anthropic_system(extra_system, block: Optional[str]) -> list[dict]:
|
||||||
"""System blocks: cache_control marks the stable managed prefix only
|
"""System blocks: cache_control marks the stable managed prefix only
|
||||||
(the API allows 4 breakpoints total — the varying doc block and caller
|
(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,
|
stop_sequences: Optional[list[str]] = None,
|
||||||
max_turns: Optional[int] = None,
|
max_turns: Optional[int] = None,
|
||||||
) -> Union[dict, Iterator[Any]]:
|
) -> Union[dict, Iterator[Any]]:
|
||||||
|
from .integrations.anthropic_sdk import build_anthropic_tools
|
||||||
|
|
||||||
_require_anthropic()
|
_require_anthropic()
|
||||||
_validate_max_turns(max_turns)
|
_validate_max_turns(max_turns)
|
||||||
if (not isinstance(messages, list) or not messages
|
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,
|
max_tokens=max_tokens,
|
||||||
messages=prepared,
|
messages=prepared,
|
||||||
model=model,
|
model=model,
|
||||||
tools=_runnable_tools(client),
|
tools=build_anthropic_tools(client),
|
||||||
system=_anthropic_system(system, block),
|
system=_anthropic_system(system, block),
|
||||||
stream=stream,
|
stream=stream,
|
||||||
# Bounded like the OpenAI surfaces (their framework default is 10).
|
# Bounded like the OpenAI surfaces (their framework default is 10).
|
||||||
|
|||||||
+3
-2
@@ -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
|
# 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.
|
# a blocking bridge call would freeze the agent event loop.
|
||||||
openai-agents = { version = ">=0.8.0", optional = true }
|
openai-agents = { version = ">=0.8.0", optional = true }
|
||||||
# messages() drives the SDK's beta tool runner; 0.68.0 is the first release
|
# messages() and as_anthropic_tools() need the SDK's beta tool runner;
|
||||||
# with tool_runner(stream/system/max_iterations) and beta_tool(input_schema).
|
# 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 }
|
anthropic = { version = ">=0.68.0", optional = true }
|
||||||
|
|
||||||
[tool.poetry.extras]
|
[tool.poetry.extras]
|
||||||
|
|||||||
@@ -484,9 +484,73 @@ def test_as_claude_mcp_local_when_installed(client):
|
|||||||
assert server.get("type") != "http"
|
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):
|
def test_agent_tools_work_without_frameworks(client, store_path, monkeypatch):
|
||||||
monkeypatch.setitem(sys.modules, "agents", None)
|
monkeypatch.setitem(sys.modules, "agents", None)
|
||||||
monkeypatch.setitem(sys.modules, "claude_agent_sdk", None)
|
monkeypatch.setitem(sys.modules, "claude_agent_sdk", None)
|
||||||
|
monkeypatch.setitem(sys.modules, "anthropic", None)
|
||||||
seed_doc(store_path, "pi-a", "report.pdf")
|
seed_doc(store_path, "pi-a", "report.pdf")
|
||||||
browse = client.agent_tools()[0]
|
browse = client.agent_tools()[0]
|
||||||
assert "report.pdf" in browse()
|
assert "report.pdf" in browse()
|
||||||
|
|||||||
Reference in New Issue
Block a user