feat(phase1): add image storage utilities
- Create backend/services/image_storage.py with 4 core functions:
- sanitize_filename(): remove unsafe chars, limit to 255 chars, convert to lowercase
- get_unique_filename(): handle collisions with UUID suffix (format: {name}_{uuid8}_{variant}.jpg)
- ensure_image_directories(): create /images/ root and category subdirs on startup
- save_image(): save bytes to /images/{category}/{filename}, returns relative path
- Create comprehensive test suite (22 tests) covering all functionality
- Integrate ensure_image_directories() into FastAPI startup event
- Directory structure: /images/{category}/{filename}
- Collision handling: auto-suffix with UUID if filename exists
- All tests passing, pathlib.Path for safe operations
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
from ._beta_runner import BetaToolRunner, BetaAsyncToolRunner, BetaStreamingToolRunner, BetaAsyncStreamingToolRunner
|
||||
from ._beta_functions import (
|
||||
ToolError,
|
||||
BetaFunctionTool,
|
||||
BetaAsyncFunctionTool,
|
||||
BetaBuiltinFunctionTool,
|
||||
BetaFunctionToolResultType,
|
||||
BetaAsyncBuiltinFunctionTool,
|
||||
beta_tool,
|
||||
beta_async_tool,
|
||||
)
|
||||
from ._beta_builtin_memory_tool import BetaAbstractMemoryTool, BetaAsyncAbstractMemoryTool
|
||||
|
||||
__all__ = [
|
||||
"beta_tool",
|
||||
"beta_async_tool",
|
||||
"BetaFunctionTool",
|
||||
"BetaAsyncFunctionTool",
|
||||
"BetaBuiltinFunctionTool",
|
||||
"BetaAsyncBuiltinFunctionTool",
|
||||
"BetaToolRunner",
|
||||
"BetaAsyncStreamingToolRunner",
|
||||
"BetaStreamingToolRunner",
|
||||
"BetaAsyncToolRunner",
|
||||
"BetaFunctionToolResultType",
|
||||
"BetaAbstractMemoryTool",
|
||||
"BetaAsyncAbstractMemoryTool",
|
||||
"ToolError",
|
||||
]
|
||||
@@ -0,0 +1,875 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
from abc import abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, List, cast
|
||||
from pathlib import Path
|
||||
from typing_extensions import override, assert_never
|
||||
|
||||
from anyio import Path as AsyncPath
|
||||
from anyio.to_thread import run_sync
|
||||
|
||||
from anthropic.types.beta import (
|
||||
BetaMemoryTool20250818ViewCommand,
|
||||
BetaMemoryTool20250818CreateCommand,
|
||||
BetaMemoryTool20250818DeleteCommand,
|
||||
BetaMemoryTool20250818InsertCommand,
|
||||
BetaMemoryTool20250818RenameCommand,
|
||||
BetaMemoryTool20250818StrReplaceCommand,
|
||||
)
|
||||
|
||||
from ..._models import construct_type_unchecked
|
||||
from ...types.beta import (
|
||||
BetaMemoryTool20250818Param,
|
||||
BetaMemoryTool20250818Command,
|
||||
BetaCacheControlEphemeralParam,
|
||||
BetaMemoryTool20250818ViewCommand,
|
||||
BetaMemoryTool20250818CreateCommand,
|
||||
BetaMemoryTool20250818DeleteCommand,
|
||||
BetaMemoryTool20250818InsertCommand,
|
||||
BetaMemoryTool20250818RenameCommand,
|
||||
BetaMemoryTool20250818StrReplaceCommand,
|
||||
)
|
||||
from ._beta_functions import (
|
||||
ToolError,
|
||||
BetaBuiltinFunctionTool,
|
||||
BetaFunctionToolResultType,
|
||||
BetaAsyncBuiltinFunctionTool,
|
||||
)
|
||||
|
||||
MAX_LINES = 999999
|
||||
LINE_NUMBER_WIDTH = len(str(MAX_LINES))
|
||||
|
||||
# Owner read/write only. Avoids 0o666 which, in environments with a permissive
|
||||
# umask (e.g. Docker where umask is often 0o000), would make memory files
|
||||
# world-readable or even world-writable.
|
||||
_FILE_CREATE_MODE = 0o600
|
||||
# The default mkdir mode is 0o777, but we want to be more restrictive for memory
|
||||
# directories to avoid them being world-accessible in environments with permissive umasks
|
||||
# (eg Docker)
|
||||
_DIR_CREATE_MODE = 0o700
|
||||
|
||||
|
||||
class BetaAbstractMemoryTool(BetaBuiltinFunctionTool):
|
||||
"""Abstract base class for memory tool implementations.
|
||||
|
||||
This class provides the interface for implementing a custom memory backend for Claude.
|
||||
|
||||
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
|
||||
|
||||
Example usage:
|
||||
|
||||
```py
|
||||
class MyMemoryTool(BetaAbstractMemoryTool):
|
||||
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
|
||||
...
|
||||
return "view result"
|
||||
|
||||
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
|
||||
...
|
||||
return "created successfully"
|
||||
|
||||
# ... implement other abstract methods
|
||||
|
||||
|
||||
client = Anthropic()
|
||||
memory_tool = MyMemoryTool()
|
||||
message = client.beta.messages.run_tools(
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "Remember that I like coffee"}],
|
||||
tools=[memory_tool],
|
||||
).until_done()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
|
||||
super().__init__()
|
||||
self._cache_control = cache_control
|
||||
|
||||
@override
|
||||
def to_dict(self) -> BetaMemoryTool20250818Param:
|
||||
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
|
||||
|
||||
if self._cache_control is not None:
|
||||
param["cache_control"] = self._cache_control
|
||||
|
||||
return param
|
||||
|
||||
@override
|
||||
def call(self, input: object) -> BetaFunctionToolResultType:
|
||||
command = cast(
|
||||
BetaMemoryTool20250818Command,
|
||||
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
|
||||
)
|
||||
return self.execute(command)
|
||||
|
||||
def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
|
||||
"""Execute a memory command and return the result.
|
||||
|
||||
This method dispatches to the appropriate handler method based on the
|
||||
command type (view, create, str_replace, insert, delete, rename).
|
||||
|
||||
You typically don't need to override this method.
|
||||
"""
|
||||
if command.command == "view":
|
||||
return self.view(command)
|
||||
elif command.command == "create":
|
||||
return self.create(command)
|
||||
elif command.command == "str_replace":
|
||||
return self.str_replace(command)
|
||||
elif command.command == "insert":
|
||||
return self.insert(command)
|
||||
elif command.command == "delete":
|
||||
return self.delete(command)
|
||||
elif command.command == "rename":
|
||||
return self.rename(command)
|
||||
elif TYPE_CHECKING: # type: ignore[unreachable]
|
||||
assert_never(command)
|
||||
else:
|
||||
raise NotImplementedError(f"Unknown command: {command.command}")
|
||||
|
||||
@abstractmethod
|
||||
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
|
||||
"""View the contents of a memory path."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
|
||||
"""Create a new memory file with the specified content."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
|
||||
"""Replace text in a memory file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
|
||||
"""Insert text at a specific line number in a memory file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
|
||||
"""Delete a memory file or directory."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
|
||||
"""Rename or move a memory file or directory."""
|
||||
pass
|
||||
|
||||
def clear_all_memory(self) -> BetaFunctionToolResultType:
|
||||
"""Clear all memory data."""
|
||||
raise NotImplementedError("clear_all_memory not implemented")
|
||||
|
||||
|
||||
class BetaAsyncAbstractMemoryTool(BetaAsyncBuiltinFunctionTool):
|
||||
"""Abstract base class for memory tool implementations.
|
||||
|
||||
This class provides the interface for implementing a custom memory backend for Claude.
|
||||
|
||||
Subclass this to create your own memory storage solution (e.g., database, cloud storage, encrypted files, etc.).
|
||||
|
||||
Example usage:
|
||||
|
||||
```py
|
||||
class MyMemoryTool(BetaAbstractMemoryTool):
|
||||
def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
|
||||
...
|
||||
return "view result"
|
||||
|
||||
def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
|
||||
...
|
||||
return "created successfully"
|
||||
|
||||
# ... implement other abstract methods
|
||||
|
||||
|
||||
client = Anthropic()
|
||||
memory_tool = MyMemoryTool()
|
||||
message = client.beta.messages.run_tools(
|
||||
model="claude-sonnet-4-5",
|
||||
messages=[{"role": "user", "content": "Remember that I like coffee"}],
|
||||
tools=[memory_tool],
|
||||
).until_done()
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, cache_control: BetaCacheControlEphemeralParam | None = None) -> None:
|
||||
super().__init__()
|
||||
self._cache_control = cache_control
|
||||
|
||||
@override
|
||||
def to_dict(self) -> BetaMemoryTool20250818Param:
|
||||
param: BetaMemoryTool20250818Param = {"type": "memory_20250818", "name": "memory"}
|
||||
|
||||
if self._cache_control is not None:
|
||||
param["cache_control"] = self._cache_control
|
||||
|
||||
return param
|
||||
|
||||
@override
|
||||
async def call(self, input: object) -> BetaFunctionToolResultType:
|
||||
command = cast(
|
||||
BetaMemoryTool20250818Command,
|
||||
construct_type_unchecked(value=input, type_=cast(Any, BetaMemoryTool20250818Command)),
|
||||
)
|
||||
return await self.execute(command)
|
||||
|
||||
async def execute(self, command: BetaMemoryTool20250818Command) -> BetaFunctionToolResultType:
|
||||
"""Execute a memory command and return the result.
|
||||
|
||||
This method dispatches to the appropriate handler method based on the
|
||||
command type (view, create, str_replace, insert, delete, rename).
|
||||
|
||||
You typically don't need to override this method.
|
||||
"""
|
||||
if command.command == "view":
|
||||
return await self.view(command)
|
||||
elif command.command == "create":
|
||||
return await self.create(command)
|
||||
elif command.command == "str_replace":
|
||||
return await self.str_replace(command)
|
||||
elif command.command == "insert":
|
||||
return await self.insert(command)
|
||||
elif command.command == "delete":
|
||||
return await self.delete(command)
|
||||
elif command.command == "rename":
|
||||
return await self.rename(command)
|
||||
elif TYPE_CHECKING: # type: ignore[unreachable]
|
||||
assert_never(command)
|
||||
else:
|
||||
raise NotImplementedError(f"Unknown command: {command.command}")
|
||||
|
||||
@abstractmethod
|
||||
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> BetaFunctionToolResultType:
|
||||
"""View the contents of a memory path."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> BetaFunctionToolResultType:
|
||||
"""Create a new memory file with the specified content."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> BetaFunctionToolResultType:
|
||||
"""Replace text in a memory file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> BetaFunctionToolResultType:
|
||||
"""Insert text at a specific line number in a memory file."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> BetaFunctionToolResultType:
|
||||
"""Delete a memory file or directory."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> BetaFunctionToolResultType:
|
||||
"""Rename or move a memory file or directory."""
|
||||
pass
|
||||
|
||||
async def clear_all_memory(self) -> BetaFunctionToolResultType:
|
||||
"""Clear all memory data."""
|
||||
raise NotImplementedError("clear_all_memory not implemented")
|
||||
|
||||
|
||||
def _atomic_write_file(target_path: Path, content: str) -> None:
|
||||
dir_path = target_path.parent
|
||||
temp_path = dir_path / f".tmp-{os.getpid()}-{uuid.uuid4()}"
|
||||
data = content.encode("utf-8")
|
||||
|
||||
try:
|
||||
fd = os.open(temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
|
||||
try:
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
written = os.write(fd, data[offset:])
|
||||
if written == 0:
|
||||
raise OSError("os.write returned 0")
|
||||
offset += written
|
||||
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
os.replace(temp_path, target_path)
|
||||
except Exception:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _validate_no_symlink_escape(target_path: Path, memory_root: Path) -> None:
|
||||
resolved_root = memory_root.resolve()
|
||||
|
||||
current = target_path
|
||||
while True:
|
||||
try:
|
||||
resolved = current.resolve()
|
||||
if resolved != resolved_root and not str(resolved).startswith(str(resolved_root) + os.sep):
|
||||
raise ToolError("Path would escape /memories directory via symlink")
|
||||
return
|
||||
except (FileNotFoundError, OSError):
|
||||
parent = current.parent
|
||||
if parent == current or current == memory_root:
|
||||
return
|
||||
current = parent
|
||||
|
||||
|
||||
def _read_file_content(full_path: Path, memory_path: str) -> str:
|
||||
try:
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(
|
||||
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
|
||||
) from err
|
||||
|
||||
|
||||
def _format_file_size(bytes_size: int) -> str:
|
||||
if bytes_size == 0:
|
||||
return "0B"
|
||||
k = 1024
|
||||
sizes = ["B", "K", "M", "G"]
|
||||
i = int(bytes_size.bit_length() - 1) // 10
|
||||
i = min(i, len(sizes) - 1)
|
||||
size = bytes_size / (k**i)
|
||||
|
||||
if size == int(size):
|
||||
return f"{int(size)}{sizes[i]}"
|
||||
else:
|
||||
return f"{size:.1f}{sizes[i]}"
|
||||
|
||||
|
||||
class BetaLocalFilesystemMemoryTool(BetaAbstractMemoryTool):
|
||||
"""File-based memory storage implementation for Claude conversations"""
|
||||
|
||||
def __init__(self, base_path: str = "./memory"):
|
||||
super().__init__()
|
||||
self.base_path = Path(base_path)
|
||||
self.memory_root = self.base_path / "memories"
|
||||
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
def _validate_path(self, path: str) -> Path:
|
||||
"""Validate and resolve memory paths"""
|
||||
if not path.startswith("/memories"):
|
||||
raise ToolError(f"Path must start with /memories, got: {path}")
|
||||
|
||||
relative_path = path[len("/memories") :].lstrip("/")
|
||||
full_path = self.memory_root / relative_path if relative_path else self.memory_root
|
||||
|
||||
resolved_path = full_path.resolve()
|
||||
resolved_root = self.memory_root.resolve()
|
||||
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
|
||||
raise ToolError(f"Path {path} would escape /memories directory")
|
||||
|
||||
_validate_no_symlink_escape(resolved_path, self.memory_root)
|
||||
|
||||
return resolved_path
|
||||
|
||||
@override
|
||||
def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
|
||||
full_path = self._validate_path(command.path)
|
||||
|
||||
if not full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if full_path.is_dir():
|
||||
items: List[tuple[str, str]] = []
|
||||
|
||||
def collect_items(dir_path: Path, relative_path: str, depth: int) -> None:
|
||||
if depth > 2:
|
||||
return
|
||||
|
||||
try:
|
||||
dir_contents = sorted(dir_path.iterdir(), key=lambda x: x.name)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for item in dir_contents:
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
|
||||
try:
|
||||
stat = item.stat()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if item.is_dir():
|
||||
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
|
||||
if depth < 2:
|
||||
collect_items(item, item_relative_path, depth + 1)
|
||||
elif item.is_file():
|
||||
items.append((_format_file_size(stat.st_size), item_relative_path))
|
||||
|
||||
collect_items(full_path, "", 1)
|
||||
|
||||
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
|
||||
dir_stat = full_path.stat()
|
||||
dir_size = _format_file_size(dir_stat.st_size)
|
||||
lines = [f"{dir_size}\t{command.path}"]
|
||||
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
|
||||
|
||||
return f"{header}\n" + "\n".join(lines)
|
||||
|
||||
elif full_path.is_file():
|
||||
content = _read_file_content(full_path, command.path)
|
||||
lines = content.split("\n")
|
||||
|
||||
if len(lines) > MAX_LINES:
|
||||
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
|
||||
|
||||
display_lines = lines
|
||||
start_num = 1
|
||||
|
||||
if command.view_range and len(command.view_range) == 2:
|
||||
start_line = max(1, command.view_range[0]) - 1
|
||||
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
|
||||
display_lines = lines[start_line:end_line]
|
||||
start_num = start_line + 1
|
||||
|
||||
numbered_lines = [
|
||||
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
|
||||
]
|
||||
|
||||
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
|
||||
else:
|
||||
raise ToolError(f"Unsupported file type for {command.path}")
|
||||
|
||||
@override
|
||||
def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
|
||||
full_path = self._validate_path(command.path)
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
try:
|
||||
fd = os.open(full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
|
||||
try:
|
||||
os.write(fd, command.file_text.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except FileExistsError as err:
|
||||
raise ToolError(f"File {command.path} already exists") from err
|
||||
|
||||
return f"File created successfully at: {command.path}"
|
||||
|
||||
@override
|
||||
def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
|
||||
full_path = self._validate_path(command.path)
|
||||
|
||||
if not full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if not full_path.is_file():
|
||||
raise ToolError(f"The path {command.path} is not a file.")
|
||||
|
||||
content = _read_file_content(full_path, command.path)
|
||||
|
||||
count = content.count(command.old_str)
|
||||
if count == 0:
|
||||
raise ToolError(
|
||||
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
|
||||
)
|
||||
elif count > 1:
|
||||
matching_lines: List[int] = []
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(command.old_str, start)
|
||||
if pos == -1:
|
||||
break
|
||||
matching_lines.append(content[:pos].count("\n") + 1)
|
||||
start = pos + 1
|
||||
raise ToolError(
|
||||
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
|
||||
)
|
||||
|
||||
pos = content.find(command.old_str)
|
||||
changed_line_index = content[:pos].count("\n")
|
||||
new_content = content.replace(command.old_str, command.new_str)
|
||||
_atomic_write_file(full_path, new_content)
|
||||
|
||||
new_lines = new_content.split("\n")
|
||||
context_start = max(0, changed_line_index - 2)
|
||||
context_end = min(len(new_lines), changed_line_index + 3)
|
||||
snippet = [
|
||||
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
|
||||
for line_num in range(context_start + 1, context_end + 1)
|
||||
]
|
||||
|
||||
return (
|
||||
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
|
||||
+ "\n".join(snippet)
|
||||
)
|
||||
|
||||
@override
|
||||
def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
|
||||
full_path = self._validate_path(command.path)
|
||||
|
||||
if not full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if not full_path.is_file():
|
||||
raise ToolError(f"The path {command.path} is not a file.")
|
||||
|
||||
content = _read_file_content(full_path, command.path)
|
||||
lines = content.splitlines()
|
||||
|
||||
if command.insert_line < 0 or command.insert_line > len(lines):
|
||||
raise ToolError(
|
||||
f"Invalid `insert_line` parameter: {command.insert_line}. "
|
||||
f"It should be within the range [0, {len(lines)}]."
|
||||
)
|
||||
|
||||
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
|
||||
new_content = "\n".join(lines)
|
||||
if not new_content.endswith("\n"):
|
||||
new_content += "\n"
|
||||
_atomic_write_file(full_path, content=new_content)
|
||||
return f"The file {command.path} has been edited."
|
||||
|
||||
@override
|
||||
def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
|
||||
full_path = self._validate_path(command.path)
|
||||
|
||||
if command.path == "/memories":
|
||||
raise ToolError("Cannot delete the /memories directory itself")
|
||||
|
||||
try:
|
||||
if full_path.is_file():
|
||||
full_path.unlink()
|
||||
elif full_path.is_dir():
|
||||
shutil.rmtree(full_path)
|
||||
else:
|
||||
raise ToolError(f"The path {command.path} does not exist")
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(f"The path {command.path} does not exist") from err
|
||||
|
||||
return f"Successfully deleted {command.path}"
|
||||
|
||||
@override
|
||||
def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
|
||||
old_full_path = self._validate_path(command.old_path)
|
||||
new_full_path = self._validate_path(command.new_path)
|
||||
|
||||
if new_full_path.exists():
|
||||
raise ToolError(f"The destination {command.new_path} already exists")
|
||||
|
||||
new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
try:
|
||||
old_full_path.rename(new_full_path)
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(f"The path {command.old_path} does not exist") from err
|
||||
|
||||
return f"Successfully renamed {command.old_path} to {command.new_path}"
|
||||
|
||||
@override
|
||||
def clear_all_memory(self) -> str:
|
||||
"""Override the base implementation to provide file system clearing."""
|
||||
if self.memory_root.exists():
|
||||
shutil.rmtree(self.memory_root)
|
||||
self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
return "All memory cleared"
|
||||
|
||||
|
||||
async def _async_atomic_write_file(target_path: AsyncPath, content: str) -> None:
|
||||
temp_path = target_path.parent / f".tmp-{os.getpid()}-{uuid.uuid4()}"
|
||||
sync_target_path = Path(str(target_path))
|
||||
sync_temp_path = Path(str(temp_path))
|
||||
data = content.encode("utf-8")
|
||||
|
||||
try:
|
||||
|
||||
def write_replace_and_sync() -> None:
|
||||
fd = os.open(sync_temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
|
||||
try:
|
||||
offset = 0
|
||||
while offset < len(data):
|
||||
written = os.write(fd, data[offset:])
|
||||
if written == 0:
|
||||
raise OSError("os.write returned 0")
|
||||
offset += written
|
||||
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
os.replace(sync_temp_path, sync_target_path)
|
||||
|
||||
await run_sync(write_replace_and_sync)
|
||||
|
||||
except Exception:
|
||||
await temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
async def _async_validate_no_symlink_escape(target_path: AsyncPath, memory_root: AsyncPath) -> None:
|
||||
sync_target = Path(str(target_path))
|
||||
sync_root = Path(str(memory_root))
|
||||
await run_sync(_validate_no_symlink_escape, sync_target, sync_root)
|
||||
|
||||
|
||||
async def _async_read_file_content(full_path: AsyncPath, memory_path: str) -> str:
|
||||
try:
|
||||
return await full_path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(
|
||||
f"The file {memory_path} no longer exists (may have been deleted or renamed concurrently)."
|
||||
) from err
|
||||
|
||||
|
||||
class BetaAsyncLocalFilesystemMemoryTool(BetaAsyncAbstractMemoryTool):
|
||||
"""Async file-based memory storage implementation for Claude conversations"""
|
||||
|
||||
def __init__(self, base_path: str = "./memory"):
|
||||
super().__init__()
|
||||
self.base_path = AsyncPath(base_path)
|
||||
self.memory_root = self.base_path / "memories"
|
||||
# Note: Directory creation is deferred to async methods since __init__ can't be async
|
||||
|
||||
async def _ensure_memory_root(self) -> None:
|
||||
"""Ensure the memory root directory exists"""
|
||||
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
async def _validate_path(self, path: str) -> AsyncPath:
|
||||
"""Validate and resolve memory paths"""
|
||||
if not path.startswith("/memories"):
|
||||
raise ToolError(f"Path must start with /memories, got: {path}")
|
||||
|
||||
relative_path = path[len("/memories") :].lstrip("/")
|
||||
full_path = self.memory_root / relative_path if relative_path else self.memory_root
|
||||
|
||||
sync_memory_root = Path(str(self.memory_root))
|
||||
sync_full_path = Path(str(full_path))
|
||||
|
||||
resolved_path = sync_full_path.resolve()
|
||||
resolved_root = sync_memory_root.resolve()
|
||||
if resolved_path != resolved_root and not str(resolved_path).startswith(str(resolved_root) + os.sep):
|
||||
raise ToolError(f"Path {path} would escape /memories directory")
|
||||
|
||||
await _async_validate_no_symlink_escape(full_path, self.memory_root)
|
||||
|
||||
return AsyncPath(resolved_path)
|
||||
|
||||
@override
|
||||
async def view(self, command: BetaMemoryTool20250818ViewCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
full_path = await self._validate_path(command.path)
|
||||
|
||||
if not await full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if await full_path.is_dir():
|
||||
items: List[tuple[str, str]] = []
|
||||
|
||||
async def collect_items(dir_path: AsyncPath, relative_path: str, depth: int) -> None:
|
||||
if depth > 2:
|
||||
return
|
||||
|
||||
try:
|
||||
dir_items = [item async for item in dir_path.iterdir()]
|
||||
dir_contents = sorted(dir_items, key=lambda x: x.name)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for item in dir_contents:
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
item_relative_path = f"{relative_path}/{item.name}" if relative_path else item.name
|
||||
try:
|
||||
sync_item = Path(str(item))
|
||||
stat = await run_sync(sync_item.stat)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if await item.is_dir():
|
||||
items.append((_format_file_size(stat.st_size), f"{item_relative_path}/"))
|
||||
if depth < 2:
|
||||
await collect_items(item, item_relative_path, depth + 1)
|
||||
elif await item.is_file():
|
||||
items.append((_format_file_size(stat.st_size), item_relative_path))
|
||||
|
||||
await collect_items(full_path, "", 1)
|
||||
|
||||
header = f"Here're the files and directories up to 2 levels deep in {command.path}, excluding hidden items:"
|
||||
sync_full_path = Path(str(full_path))
|
||||
dir_stat = await run_sync(sync_full_path.stat)
|
||||
dir_size = _format_file_size(dir_stat.st_size)
|
||||
lines = [f"{dir_size}\t{command.path}"]
|
||||
lines.extend([f"{size}\t{command.path}/{path}" for size, path in items])
|
||||
|
||||
return f"{header}\n" + "\n".join(lines)
|
||||
|
||||
elif await full_path.is_file():
|
||||
content = await _async_read_file_content(full_path, command.path)
|
||||
lines = content.split("\n")
|
||||
|
||||
if len(lines) > MAX_LINES:
|
||||
raise ToolError(f"File {command.path} exceeds maximum line limit of 999,999 lines.")
|
||||
|
||||
display_lines = lines
|
||||
start_num = 1
|
||||
|
||||
if command.view_range and len(command.view_range) == 2:
|
||||
start_line = max(1, command.view_range[0]) - 1
|
||||
end_line = len(lines) if command.view_range[1] == -1 else command.view_range[1]
|
||||
display_lines = lines[start_line:end_line]
|
||||
start_num = start_line + 1
|
||||
|
||||
numbered_lines = [
|
||||
f"{str(i + start_num).rjust(LINE_NUMBER_WIDTH)}\t{line}" for i, line in enumerate(display_lines)
|
||||
]
|
||||
|
||||
return f"Here's the content of {command.path} with line numbers:\n" + "\n".join(numbered_lines)
|
||||
else:
|
||||
raise ToolError(f"Unsupported file type for {command.path}")
|
||||
|
||||
@override
|
||||
async def create(self, command: BetaMemoryTool20250818CreateCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
full_path = await self._validate_path(command.path)
|
||||
|
||||
await full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
try:
|
||||
sync_full_path = Path(str(full_path))
|
||||
|
||||
def create_exclusive() -> None:
|
||||
fd = os.open(sync_full_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, _FILE_CREATE_MODE)
|
||||
try:
|
||||
os.write(fd, command.file_text.encode("utf-8"))
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
await run_sync(create_exclusive)
|
||||
except FileExistsError as err:
|
||||
raise ToolError(f"File {command.path} already exists") from err
|
||||
|
||||
return f"File created successfully at: {command.path}"
|
||||
|
||||
@override
|
||||
async def str_replace(self, command: BetaMemoryTool20250818StrReplaceCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
full_path = await self._validate_path(command.path)
|
||||
|
||||
if not await full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if not await full_path.is_file():
|
||||
raise ToolError(f"The path {command.path} is not a file.")
|
||||
|
||||
content = await _async_read_file_content(full_path, command.path)
|
||||
|
||||
count = content.count(command.old_str)
|
||||
if count == 0:
|
||||
raise ToolError(
|
||||
f"No replacement was performed, old_str `{command.old_str}` did not appear verbatim in {command.path}."
|
||||
)
|
||||
elif count > 1:
|
||||
matching_lines: List[int] = []
|
||||
start = 0
|
||||
while True:
|
||||
pos = content.find(command.old_str, start)
|
||||
if pos == -1:
|
||||
break
|
||||
matching_lines.append(content[:pos].count("\n") + 1)
|
||||
start = pos + 1
|
||||
raise ToolError(
|
||||
f"No replacement was performed. Multiple occurrences of old_str `{command.old_str}` in lines: {', '.join(map(str, matching_lines))}. Please ensure it is unique"
|
||||
)
|
||||
|
||||
pos = content.find(command.old_str)
|
||||
changed_line_index = content[:pos].count("\n")
|
||||
new_content = content.replace(command.old_str, command.new_str)
|
||||
await _async_atomic_write_file(full_path, new_content)
|
||||
|
||||
new_lines = new_content.split("\n")
|
||||
context_start = max(0, changed_line_index - 2)
|
||||
context_end = min(len(new_lines), changed_line_index + 3)
|
||||
snippet = [
|
||||
f"{str(line_num).rjust(LINE_NUMBER_WIDTH)}\t{new_lines[line_num - 1]}"
|
||||
for line_num in range(context_start + 1, context_end + 1)
|
||||
]
|
||||
|
||||
return (
|
||||
f"The memory file has been edited. Here is the snippet showing the change (with line numbers):\n"
|
||||
+ "\n".join(snippet)
|
||||
)
|
||||
|
||||
@override
|
||||
async def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
full_path = await self._validate_path(command.path)
|
||||
|
||||
if not await full_path.exists():
|
||||
raise ToolError(f"The path {command.path} does not exist. Please provide a valid path.")
|
||||
|
||||
if not await full_path.is_file():
|
||||
raise ToolError(f"The path {command.path} is not a file.")
|
||||
|
||||
content = await _async_read_file_content(full_path, command.path)
|
||||
lines = content.splitlines()
|
||||
|
||||
if command.insert_line < 0 or command.insert_line > len(lines):
|
||||
raise ToolError(
|
||||
f"Invalid `insert_line` parameter: {command.insert_line}. "
|
||||
f"It should be within the range [0, {len(lines)}]."
|
||||
)
|
||||
|
||||
lines.insert(command.insert_line, command.insert_text.rstrip("\n"))
|
||||
new_content = "\n".join(lines)
|
||||
if not new_content.endswith("\n"):
|
||||
new_content += "\n"
|
||||
await _async_atomic_write_file(full_path, content=new_content)
|
||||
return f"The file {command.path} has been edited."
|
||||
|
||||
@override
|
||||
async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
full_path = await self._validate_path(command.path)
|
||||
|
||||
if command.path == "/memories":
|
||||
raise ToolError("Cannot delete the /memories directory itself")
|
||||
|
||||
try:
|
||||
if await full_path.is_file():
|
||||
await full_path.unlink()
|
||||
elif await full_path.is_dir():
|
||||
await run_sync(shutil.rmtree, str(full_path))
|
||||
else:
|
||||
raise ToolError(f"The path {command.path} does not exist")
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(f"The path {command.path} does not exist") from err
|
||||
|
||||
return f"Successfully deleted {command.path}"
|
||||
|
||||
@override
|
||||
async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str:
|
||||
await self._ensure_memory_root()
|
||||
old_full_path = await self._validate_path(command.old_path)
|
||||
new_full_path = await self._validate_path(command.new_path)
|
||||
|
||||
if await new_full_path.exists():
|
||||
raise ToolError(f"The destination {command.new_path} already exists")
|
||||
|
||||
await new_full_path.parent.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
|
||||
try:
|
||||
await old_full_path.rename(new_full_path)
|
||||
except FileNotFoundError as err:
|
||||
raise ToolError(f"The path {command.old_path} does not exist") from err
|
||||
|
||||
return f"Successfully renamed {command.old_path} to {command.new_path}"
|
||||
|
||||
@override
|
||||
async def clear_all_memory(self) -> str:
|
||||
"""Override the base implementation to provide file system clearing."""
|
||||
if await self.memory_root.exists():
|
||||
await run_sync(shutil.rmtree, str(self.memory_root))
|
||||
await self.memory_root.mkdir(parents=True, exist_ok=True, mode=_DIR_CREATE_MODE)
|
||||
return "All memory cleared"
|
||||
@@ -0,0 +1,55 @@
|
||||
from typing import TypedDict
|
||||
from typing_extensions import Required
|
||||
|
||||
DEFAULT_SUMMARY_PROMPT = """You have been working on the task described above but have not yet completed it. Write a continuation summary that will allow you (or another instance of yourself) to resume work efficiently in a future context window where the conversation history will be replaced with this summary. Your summary should be structured, concise, and actionable. Include:
|
||||
1. Task Overview
|
||||
The user's core request and success criteria
|
||||
Any clarifications or constraints they specified
|
||||
2. Current State
|
||||
What has been completed so far
|
||||
Files created, modified, or analyzed (with paths if relevant)
|
||||
Key outputs or artifacts produced
|
||||
3. Important Discoveries
|
||||
Technical constraints or requirements uncovered
|
||||
Decisions made and their rationale
|
||||
Errors encountered and how they were resolved
|
||||
What approaches were tried that didn't work (and why)
|
||||
4. Next Steps
|
||||
Specific actions needed to complete the task
|
||||
Any blockers or open questions to resolve
|
||||
Priority order if multiple steps remain
|
||||
5. Context to Preserve
|
||||
User preferences or style requirements
|
||||
Domain-specific details that aren't obvious
|
||||
Any promises made to the user
|
||||
Be concise but complete—err on the side of including information that would prevent duplicate work or repeated mistakes. Write in a way that enables immediate resumption of the task.
|
||||
Wrap your summary in <summary></summary> tags."""
|
||||
|
||||
DEFAULT_THRESHOLD = 100_000
|
||||
|
||||
|
||||
class CompactionControl(TypedDict, total=False):
|
||||
"""Client-side compaction control configuration.
|
||||
|
||||
.. deprecated::
|
||||
Use server-side compaction instead by passing
|
||||
``edits=[{"type": "compact_20260112"}]`` in the params passed to ``tool_runner()``.
|
||||
See https://platform.claude.com/docs/en/build-with-claude/compaction
|
||||
"""
|
||||
context_token_threshold: int
|
||||
"""The context token threshold at which to trigger compaction.
|
||||
|
||||
When the cumulative token count (input + output) across all messages exceeds this threshold,
|
||||
the message history will be automatically summarized and compressed. Defaults to 100,000 tokens.
|
||||
"""
|
||||
|
||||
model: str
|
||||
"""
|
||||
The model to use for generating the compaction summary.
|
||||
If not specified, defaults to the same model used for the tool runner.
|
||||
"""
|
||||
|
||||
summary_prompt: str
|
||||
"""The prompt used to instruct the model on how to generate the summary."""
|
||||
|
||||
enabled: Required[bool]
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Union, Generic, TypeVar, Callable, Iterable, Coroutine, cast, overload
|
||||
from inspect import iscoroutinefunction
|
||||
from typing_extensions import Literal, TypeAlias, override
|
||||
|
||||
import pydantic
|
||||
import docstring_parser
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ... import _compat
|
||||
from ..._utils import is_dict
|
||||
from ..._compat import cached_property
|
||||
from ..._models import TypeAdapter
|
||||
from ...types.beta import BetaToolParam, BetaToolUnionParam, BetaCacheControlEphemeralParam
|
||||
from ..._utils._utils import CallableT
|
||||
from ...types.tool_param import InputSchema
|
||||
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BetaFunctionToolResultType: TypeAlias = Union[str, Iterable[BetaContent]]
|
||||
|
||||
|
||||
class ToolError(Exception):
|
||||
"""Error that can be raised from a tool to return structured content with ``is_error: True``.
|
||||
|
||||
When the tool runner catches this error, it will use the :attr:`content`
|
||||
property as the tool result instead of ``repr(exc)``.
|
||||
|
||||
Example::
|
||||
|
||||
raise ToolError(
|
||||
[
|
||||
{"type": "text", "text": "Error details here"},
|
||||
{"type": "image", "source": {"type": "base64", "data": "...", "media_type": "image/png"}},
|
||||
]
|
||||
)
|
||||
"""
|
||||
|
||||
content: BetaFunctionToolResultType
|
||||
|
||||
def __init__(self, content: BetaFunctionToolResultType) -> None:
|
||||
if isinstance(content, str):
|
||||
message = content
|
||||
else:
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
text = block.get("text")
|
||||
if text is not None:
|
||||
parts.append(str(text))
|
||||
else:
|
||||
parts.append(f"[{block.get('type', 'unknown')}]")
|
||||
message = " ".join(parts) if parts else "Tool error"
|
||||
super().__init__(message)
|
||||
self.content = content
|
||||
|
||||
|
||||
Function = Callable[..., BetaFunctionToolResultType]
|
||||
FunctionT = TypeVar("FunctionT", bound=Function)
|
||||
|
||||
AsyncFunction = Callable[..., Coroutine[Any, Any, BetaFunctionToolResultType]]
|
||||
AsyncFunctionT = TypeVar("AsyncFunctionT", bound=AsyncFunction)
|
||||
|
||||
|
||||
class BetaBuiltinFunctionTool(ABC):
|
||||
@abstractmethod
|
||||
def to_dict(self) -> BetaToolUnionParam: ...
|
||||
|
||||
@abstractmethod
|
||||
def call(self, input: object) -> BetaFunctionToolResultType: ...
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
raw = self.to_dict()
|
||||
if "mcp_server_name" in raw:
|
||||
return raw["mcp_server_name"]
|
||||
return raw["name"]
|
||||
|
||||
|
||||
class BetaAsyncBuiltinFunctionTool(ABC):
|
||||
@abstractmethod
|
||||
def to_dict(self) -> BetaToolUnionParam: ...
|
||||
|
||||
@abstractmethod
|
||||
async def call(self, input: object) -> BetaFunctionToolResultType: ...
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
raw = self.to_dict()
|
||||
if "mcp_server_name" in raw:
|
||||
return raw["mcp_server_name"]
|
||||
return raw["name"]
|
||||
|
||||
|
||||
class BaseFunctionTool(Generic[CallableT]):
|
||||
func: CallableT
|
||||
"""The function this tool is wrapping"""
|
||||
|
||||
name: str
|
||||
"""The name of the tool that will be sent to the API"""
|
||||
|
||||
description: str
|
||||
|
||||
input_schema: InputSchema
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
func: CallableT,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> None:
|
||||
if _compat.PYDANTIC_V1:
|
||||
raise RuntimeError("Tool functions are only supported with Pydantic v2")
|
||||
|
||||
self.func = func
|
||||
self._func_with_validate = pydantic.validate_call(func)
|
||||
self.name = name or func.__name__
|
||||
self._defer_loading = defer_loading
|
||||
self._cache_control = cache_control
|
||||
self._allowed_callers = allowed_callers
|
||||
self._eager_input_streaming = eager_input_streaming
|
||||
self._input_examples = input_examples
|
||||
self._strict = strict
|
||||
|
||||
self.description = description or self._get_description_from_docstring()
|
||||
|
||||
if input_schema is not None:
|
||||
if isinstance(input_schema, type):
|
||||
self.input_schema: InputSchema = input_schema.model_json_schema()
|
||||
else:
|
||||
self.input_schema = input_schema
|
||||
else:
|
||||
self.input_schema = self._create_schema_from_function()
|
||||
|
||||
@property
|
||||
def __call__(self) -> CallableT:
|
||||
return self.func
|
||||
|
||||
def to_dict(self) -> BetaToolParam:
|
||||
defn: BetaToolParam = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"input_schema": self.input_schema,
|
||||
}
|
||||
if self._defer_loading is not None:
|
||||
defn["defer_loading"] = self._defer_loading
|
||||
if self._cache_control is not None:
|
||||
defn["cache_control"] = self._cache_control
|
||||
if self._allowed_callers is not None:
|
||||
defn["allowed_callers"] = self._allowed_callers
|
||||
if self._eager_input_streaming is not None:
|
||||
defn["eager_input_streaming"] = self._eager_input_streaming
|
||||
if self._input_examples is not None:
|
||||
defn["input_examples"] = self._input_examples
|
||||
if self._strict is not None:
|
||||
defn["strict"] = self._strict
|
||||
return defn
|
||||
|
||||
@cached_property
|
||||
def _parsed_docstring(self) -> docstring_parser.Docstring:
|
||||
return docstring_parser.parse(self.func.__doc__ or "")
|
||||
|
||||
def _get_description_from_docstring(self) -> str:
|
||||
"""Extract description from parsed docstring."""
|
||||
if self._parsed_docstring.short_description:
|
||||
description = self._parsed_docstring.short_description
|
||||
if self._parsed_docstring.long_description:
|
||||
description += f"\n\n{self._parsed_docstring.long_description}"
|
||||
return description
|
||||
return ""
|
||||
|
||||
def _create_schema_from_function(self) -> InputSchema:
|
||||
"""Create JSON schema from function signature using pydantic."""
|
||||
|
||||
from pydantic_core import CoreSchema
|
||||
from pydantic.json_schema import JsonSchemaValue, GenerateJsonSchema
|
||||
from pydantic_core.core_schema import ArgumentsParameter
|
||||
|
||||
class CustomGenerateJsonSchema(GenerateJsonSchema):
|
||||
def __init__(self, *, func: Callable[..., Any], parsed_docstring: Any) -> None:
|
||||
super().__init__()
|
||||
self._func = func
|
||||
self._parsed_docstring = parsed_docstring
|
||||
|
||||
def __call__(self, *_args: Any, **_kwds: Any) -> "CustomGenerateJsonSchema": # noqa: ARG002
|
||||
return self
|
||||
|
||||
@override
|
||||
def kw_arguments_schema(
|
||||
self,
|
||||
arguments: "list[ArgumentsParameter]",
|
||||
var_kwargs_schema: CoreSchema | None,
|
||||
) -> JsonSchemaValue:
|
||||
schema = super().kw_arguments_schema(arguments, var_kwargs_schema)
|
||||
if schema.get("type") != "object":
|
||||
return schema
|
||||
|
||||
properties = schema.get("properties")
|
||||
if not properties or not is_dict(properties):
|
||||
return schema
|
||||
|
||||
# Add parameter descriptions from docstring
|
||||
for param in self._parsed_docstring.params:
|
||||
prop_schema = properties.get(param.arg_name)
|
||||
if not prop_schema or not is_dict(prop_schema):
|
||||
continue
|
||||
|
||||
if param.description and "description" not in prop_schema:
|
||||
prop_schema["description"] = param.description
|
||||
|
||||
return schema
|
||||
|
||||
schema_generator = CustomGenerateJsonSchema(func=self.func, parsed_docstring=self._parsed_docstring)
|
||||
return self._adapter.json_schema(schema_generator=schema_generator) # type: ignore
|
||||
|
||||
@cached_property
|
||||
def _adapter(self) -> TypeAdapter[Any]:
|
||||
return TypeAdapter(self._func_with_validate)
|
||||
|
||||
|
||||
class BetaFunctionTool(BaseFunctionTool[FunctionT]):
|
||||
def call(self, input: object) -> BetaFunctionToolResultType:
|
||||
if iscoroutinefunction(self.func):
|
||||
raise RuntimeError("Cannot call a coroutine function synchronously. Use `@async_tool` instead.")
|
||||
|
||||
if not is_dict(input):
|
||||
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
|
||||
|
||||
try:
|
||||
return self._func_with_validate(**cast(Any, input))
|
||||
except pydantic.ValidationError as e:
|
||||
raise ValueError(f"Invalid arguments for function {self.name}") from e
|
||||
|
||||
|
||||
class BetaAsyncFunctionTool(BaseFunctionTool[AsyncFunctionT]):
|
||||
async def call(self, input: object) -> BetaFunctionToolResultType:
|
||||
if not iscoroutinefunction(self.func):
|
||||
raise RuntimeError("Cannot call a synchronous function asynchronously. Use `@tool` instead.")
|
||||
|
||||
if not is_dict(input):
|
||||
raise TypeError(f"Input must be a dictionary, got {type(input).__name__}")
|
||||
|
||||
try:
|
||||
return await self._func_with_validate(**cast(Any, input))
|
||||
except pydantic.ValidationError as e:
|
||||
raise ValueError(f"Invalid arguments for function {self.name}") from e
|
||||
|
||||
|
||||
@overload
|
||||
def beta_tool(func: FunctionT) -> BetaFunctionTool[FunctionT]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def beta_tool(
|
||||
func: FunctionT,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaFunctionTool[FunctionT]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def beta_tool(
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> Callable[[FunctionT], BetaFunctionTool[FunctionT]]: ...
|
||||
|
||||
|
||||
def beta_tool(
|
||||
func: FunctionT | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaFunctionTool[FunctionT] | Callable[[FunctionT], BetaFunctionTool[FunctionT]]:
|
||||
"""Create a FunctionTool from a function with automatic schema inference.
|
||||
|
||||
Can be used as a decorator with or without parentheses:
|
||||
|
||||
@function_tool
|
||||
def my_func(x: int) -> str: ...
|
||||
|
||||
@function_tool()
|
||||
def my_func(x: int) -> str: ...
|
||||
|
||||
@function_tool(name="custom_name")
|
||||
def my_func(x: int) -> str: ...
|
||||
"""
|
||||
if _compat.PYDANTIC_V1:
|
||||
raise RuntimeError("Tool functions are only supported with Pydantic v2")
|
||||
|
||||
def _make(fn: FunctionT) -> BetaFunctionTool[FunctionT]:
|
||||
return BetaFunctionTool(
|
||||
fn,
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
defer_loading=defer_loading,
|
||||
cache_control=cache_control,
|
||||
allowed_callers=allowed_callers,
|
||||
eager_input_streaming=eager_input_streaming,
|
||||
input_examples=input_examples,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
if func is not None:
|
||||
return _make(func)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@overload
|
||||
def beta_async_tool(func: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def beta_async_tool(
|
||||
func: AsyncFunctionT,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaAsyncFunctionTool[AsyncFunctionT]: ... # noqa: E501
|
||||
|
||||
|
||||
@overload
|
||||
def beta_async_tool(
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]: ...
|
||||
|
||||
|
||||
def beta_async_tool(
|
||||
func: AsyncFunctionT | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
input_schema: InputSchema | type[BaseModel] | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaAsyncFunctionTool[AsyncFunctionT] | Callable[[AsyncFunctionT], BetaAsyncFunctionTool[AsyncFunctionT]]:
|
||||
"""Create an AsyncFunctionTool from a function with automatic schema inference.
|
||||
|
||||
Can be used as a decorator with or without parentheses:
|
||||
|
||||
@async_tool
|
||||
async def my_func(x: int) -> str: ...
|
||||
|
||||
@async_tool()
|
||||
async def my_func(x: int) -> str: ...
|
||||
|
||||
@async_tool(name="custom_name")
|
||||
async def my_func(x: int) -> str: ...
|
||||
"""
|
||||
if _compat.PYDANTIC_V1:
|
||||
raise RuntimeError("Tool functions are only supported with Pydantic v2")
|
||||
|
||||
def _make(fn: AsyncFunctionT) -> BetaAsyncFunctionTool[AsyncFunctionT]:
|
||||
return BetaAsyncFunctionTool(
|
||||
fn,
|
||||
name=name,
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
defer_loading=defer_loading,
|
||||
cache_control=cache_control,
|
||||
allowed_callers=allowed_callers,
|
||||
eager_input_streaming=eager_input_streaming,
|
||||
input_examples=input_examples,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
if func is not None:
|
||||
return _make(func)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
BetaRunnableTool = Union[BetaFunctionTool[Any], BetaBuiltinFunctionTool]
|
||||
BetaAsyncRunnableTool = Union[BetaAsyncFunctionTool[Any], BetaAsyncBuiltinFunctionTool]
|
||||
@@ -0,0 +1,685 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
List,
|
||||
Union,
|
||||
Generic,
|
||||
TypeVar,
|
||||
Callable,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Coroutine,
|
||||
AsyncIterator,
|
||||
)
|
||||
from contextlib import contextmanager, asynccontextmanager
|
||||
from typing_extensions import TypedDict, override
|
||||
|
||||
import httpx
|
||||
|
||||
from ..._types import Body, Query, Headers, NotGiven
|
||||
from ..._utils import consume_sync_iterator, consume_async_iterator
|
||||
from ...types.beta import BetaMessage, BetaMessageParam
|
||||
from ._beta_functions import (
|
||||
ToolError,
|
||||
BetaFunctionTool,
|
||||
BetaRunnableTool,
|
||||
BetaAsyncFunctionTool,
|
||||
BetaAsyncRunnableTool,
|
||||
BetaBuiltinFunctionTool,
|
||||
BetaAsyncBuiltinFunctionTool,
|
||||
)
|
||||
from .._stainless_helpers import stainless_helper_header
|
||||
from ._beta_compaction_control import DEFAULT_THRESHOLD, DEFAULT_SUMMARY_PROMPT, CompactionControl
|
||||
from ..streaming._beta_messages import BetaMessageStream, BetaAsyncMessageStream
|
||||
from ...types.beta.parsed_beta_message import ResponseFormatT, ParsedBetaMessage, ParsedBetaContentBlock
|
||||
from ...types.beta.message_create_params import ParseMessageCreateParamsBase
|
||||
from ...types.beta.beta_tool_result_block_param import BetaToolResultBlockParam
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..._client import Anthropic, AsyncAnthropic
|
||||
|
||||
|
||||
AnyFunctionToolT = TypeVar(
|
||||
"AnyFunctionToolT",
|
||||
bound=Union[
|
||||
BetaFunctionTool[Any], BetaAsyncFunctionTool[Any], BetaBuiltinFunctionTool, BetaAsyncBuiltinFunctionTool
|
||||
],
|
||||
)
|
||||
RunnerItemT = TypeVar("RunnerItemT")
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RequestOptions(TypedDict, total=False):
|
||||
extra_headers: Headers | None
|
||||
extra_query: Query | None
|
||||
extra_body: Body | None
|
||||
timeout: float | httpx.Timeout | None | NotGiven
|
||||
|
||||
|
||||
class BaseToolRunner(Generic[AnyFunctionToolT, ResponseFormatT]):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
params: ParseMessageCreateParamsBase[ResponseFormatT],
|
||||
options: RequestOptions,
|
||||
tools: Iterable[AnyFunctionToolT],
|
||||
max_iterations: int | None = None,
|
||||
compaction_control: CompactionControl | None = None,
|
||||
) -> None:
|
||||
self._tools_by_name = {tool.name: tool for tool in tools}
|
||||
self._params: ParseMessageCreateParamsBase[ResponseFormatT] = {
|
||||
**params,
|
||||
"messages": [message for message in params["messages"]],
|
||||
}
|
||||
helper_header = stainless_helper_header(
|
||||
tools=self._tools_by_name.values(),
|
||||
messages=params.get("messages"),
|
||||
)
|
||||
if helper_header:
|
||||
merged_headers = {**helper_header, **(options.get("extra_headers") or {})}
|
||||
options = {**options, "extra_headers": merged_headers}
|
||||
self._options = options
|
||||
self._messages_modified = False
|
||||
self._cached_tool_call_response: BetaMessageParam | None = None
|
||||
self._max_iterations = max_iterations
|
||||
self._iteration_count = 0
|
||||
self._compaction_control = compaction_control
|
||||
|
||||
def set_messages_params(
|
||||
self,
|
||||
params: ParseMessageCreateParamsBase[ResponseFormatT]
|
||||
| Callable[[ParseMessageCreateParamsBase[ResponseFormatT]], ParseMessageCreateParamsBase[ResponseFormatT]],
|
||||
) -> None:
|
||||
"""
|
||||
Update the parameters for the next API call. This invalidates any cached tool responses.
|
||||
|
||||
Args:
|
||||
params (ParsedMessageCreateParamsBase[ResponseFormatT] | Callable): Either new parameters or a function to mutate existing parameters
|
||||
"""
|
||||
if callable(params):
|
||||
params = params(self._params)
|
||||
self._params = params
|
||||
|
||||
def append_messages(self, *messages: BetaMessageParam | ParsedBetaMessage[ResponseFormatT]) -> None:
|
||||
"""Add one or more messages to the conversation history.
|
||||
|
||||
This invalidates the cached tool response, i.e. if tools were already called, then they will
|
||||
be called again on the next loop iteration.
|
||||
"""
|
||||
message_params: List[BetaMessageParam] = [
|
||||
{"role": message.role, "content": message.content} if isinstance(message, BetaMessage) else message
|
||||
for message in messages
|
||||
]
|
||||
self._messages_modified = True
|
||||
self.set_messages_params(lambda params: {**params, "messages": [*params["messages"], *message_params]})
|
||||
self._cached_tool_call_response = None
|
||||
|
||||
def _should_stop(self) -> bool:
|
||||
if self._max_iterations is not None and self._iteration_count >= self._max_iterations:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class BaseSyncToolRunner(BaseToolRunner[BetaRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
params: ParseMessageCreateParamsBase[ResponseFormatT],
|
||||
options: RequestOptions,
|
||||
tools: Iterable[BetaRunnableTool],
|
||||
client: Anthropic,
|
||||
max_iterations: int | None = None,
|
||||
compaction_control: CompactionControl | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
params=params,
|
||||
options=options,
|
||||
tools=tools,
|
||||
max_iterations=max_iterations,
|
||||
compaction_control=compaction_control,
|
||||
)
|
||||
self._client = client
|
||||
|
||||
if compaction_control is not None and compaction_control.get("enabled"):
|
||||
warnings.warn(
|
||||
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
|
||||
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
|
||||
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
self._iterator = self.__run__()
|
||||
self._last_message: (
|
||||
Callable[[], ParsedBetaMessage[ResponseFormatT]] | ParsedBetaMessage[ResponseFormatT] | None
|
||||
) = None
|
||||
|
||||
def __next__(self) -> RunnerItemT:
|
||||
return self._iterator.__next__()
|
||||
|
||||
def __iter__(self) -> Iterator[RunnerItemT]:
|
||||
for item in self._iterator:
|
||||
yield item
|
||||
|
||||
@abstractmethod
|
||||
@contextmanager
|
||||
def _handle_request(self) -> Iterator[RunnerItemT]:
|
||||
raise NotImplementedError()
|
||||
yield # type: ignore[unreachable]
|
||||
|
||||
def _check_and_compact(self) -> bool:
|
||||
"""
|
||||
Check token usage and compact messages if threshold exceeded.
|
||||
Returns True if compaction was performed, False otherwise.
|
||||
"""
|
||||
if self._compaction_control is None or not self._compaction_control["enabled"]:
|
||||
return False
|
||||
|
||||
message = self._get_last_message()
|
||||
tokens_used = 0
|
||||
if message is not None:
|
||||
total_input_tokens = (
|
||||
message.usage.input_tokens
|
||||
+ (message.usage.cache_creation_input_tokens or 0)
|
||||
+ (message.usage.cache_read_input_tokens or 0)
|
||||
)
|
||||
tokens_used = total_input_tokens + message.usage.output_tokens
|
||||
|
||||
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
|
||||
|
||||
if tokens_used < threshold:
|
||||
return False
|
||||
|
||||
# Perform compaction
|
||||
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
|
||||
|
||||
model = self._compaction_control.get("model", self._params["model"])
|
||||
|
||||
messages = list(self._params["messages"])
|
||||
|
||||
if messages[-1]["role"] == "assistant":
|
||||
# Remove tool_use blocks from the last message to avoid 400 error
|
||||
# (tool_use requires tool_result, which we don't have yet)
|
||||
non_tool_blocks = [
|
||||
block
|
||||
for block in messages[-1]["content"]
|
||||
if isinstance(block, dict) and block.get("type") != "tool_use"
|
||||
]
|
||||
|
||||
if non_tool_blocks:
|
||||
messages[-1]["content"] = non_tool_blocks
|
||||
else:
|
||||
messages.pop()
|
||||
|
||||
messages = [
|
||||
*messages,
|
||||
BetaMessageParam(
|
||||
role="user",
|
||||
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
|
||||
),
|
||||
]
|
||||
|
||||
response = self._client.beta.messages.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=self._params["max_tokens"],
|
||||
extra_headers={"X-Stainless-Helper": "compaction"},
|
||||
)
|
||||
|
||||
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
|
||||
|
||||
first_content = list(response.content)[0]
|
||||
|
||||
if first_content.type != "text":
|
||||
raise ValueError("Compaction response content is not of type 'text'")
|
||||
|
||||
self.set_messages_params(
|
||||
lambda params: {
|
||||
**params,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": first_content.text,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
def __run__(self) -> Iterator[RunnerItemT]:
|
||||
while not self._should_stop():
|
||||
with self._handle_request() as item:
|
||||
yield item
|
||||
message = self._get_last_message()
|
||||
assert message is not None
|
||||
|
||||
# Update container from response for programmatic tool calling support
|
||||
last_assistant_message = self._get_last_assistant_message()
|
||||
if last_assistant_message is not None and last_assistant_message.container is not None:
|
||||
self._params["container"] = last_assistant_message.container.id
|
||||
|
||||
self._iteration_count += 1
|
||||
|
||||
# If the compaction was performed, skip tool call generation this iteration
|
||||
if not self._check_and_compact():
|
||||
response = self.generate_tool_call_response()
|
||||
if response is None:
|
||||
log.debug("Tool call was not requested, exiting from tool runner loop.")
|
||||
return
|
||||
|
||||
if not self._messages_modified:
|
||||
self.append_messages(message, response)
|
||||
|
||||
self._messages_modified = False
|
||||
self._cached_tool_call_response = None
|
||||
|
||||
def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
|
||||
"""
|
||||
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
|
||||
If it has, it simply returns the last message.
|
||||
"""
|
||||
consume_sync_iterator(self)
|
||||
last_message = self._get_last_message()
|
||||
assert last_message is not None
|
||||
return last_message
|
||||
|
||||
def generate_tool_call_response(self) -> BetaMessageParam | None:
|
||||
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
|
||||
|
||||
Note the tool call response is cached, repeated calls to this method will return the same response.
|
||||
|
||||
None can be returned if no tool call was applicable.
|
||||
"""
|
||||
if self._cached_tool_call_response is not None:
|
||||
log.debug("Returning cached tool call response.")
|
||||
return self._cached_tool_call_response
|
||||
response = self._generate_tool_call_response()
|
||||
self._cached_tool_call_response = response
|
||||
return response
|
||||
|
||||
def _generate_tool_call_response(self) -> BetaMessageParam | None:
|
||||
content = self._get_last_assistant_message_content()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
tool_use_blocks = [block for block in content if block.type == "tool_use"]
|
||||
if not tool_use_blocks:
|
||||
return None
|
||||
|
||||
results: list[BetaToolResultBlockParam] = []
|
||||
|
||||
for tool_use in tool_use_blocks:
|
||||
tool = self._tools_by_name.get(tool_use.name)
|
||||
if tool is None:
|
||||
warnings.warn(
|
||||
f"Tool '{tool_use.name}' not found in tool runner. "
|
||||
f"Available tools: {list(self._tools_by_name.keys())}. "
|
||||
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
|
||||
f"Otherwise, pass the tool using `beta_tool(func)` or a `@beta_tool` decorated function.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": f"Error: Tool '{tool_use.name}' not found",
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = tool.call(tool_use.input)
|
||||
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
|
||||
except ToolError as exc:
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": exc.content,
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": repr(exc),
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {"role": "user", "content": results}
|
||||
|
||||
def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
|
||||
if callable(self._last_message):
|
||||
return self._last_message()
|
||||
return self._last_message
|
||||
|
||||
def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
|
||||
last_message = self._get_last_message()
|
||||
if last_message is None or last_message.role != "assistant" or not last_message.content:
|
||||
return None
|
||||
|
||||
return last_message
|
||||
|
||||
def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
|
||||
last_assistant_message = self._get_last_assistant_message()
|
||||
if last_assistant_message is None:
|
||||
return None
|
||||
|
||||
return last_assistant_message.content
|
||||
|
||||
|
||||
class BetaToolRunner(BaseSyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
|
||||
@override
|
||||
@contextmanager
|
||||
def _handle_request(self) -> Iterator[ParsedBetaMessage[ResponseFormatT]]:
|
||||
message = self._client.beta.messages.parse(**self._params, **self._options)
|
||||
self._last_message = message
|
||||
yield message
|
||||
|
||||
|
||||
class BetaStreamingToolRunner(BaseSyncToolRunner[BetaMessageStream[ResponseFormatT], ResponseFormatT]):
|
||||
@override
|
||||
@contextmanager
|
||||
def _handle_request(self) -> Iterator[BetaMessageStream[ResponseFormatT]]:
|
||||
with self._client.beta.messages.stream(**self._params, **self._options) as stream:
|
||||
self._last_message = stream.get_final_message
|
||||
yield stream
|
||||
|
||||
|
||||
class BaseAsyncToolRunner(
|
||||
BaseToolRunner[BetaAsyncRunnableTool, ResponseFormatT], Generic[RunnerItemT, ResponseFormatT], ABC
|
||||
):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
params: ParseMessageCreateParamsBase[ResponseFormatT],
|
||||
options: RequestOptions,
|
||||
tools: Iterable[BetaAsyncRunnableTool],
|
||||
client: AsyncAnthropic,
|
||||
max_iterations: int | None = None,
|
||||
compaction_control: CompactionControl | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
params=params,
|
||||
options=options,
|
||||
tools=tools,
|
||||
max_iterations=max_iterations,
|
||||
compaction_control=compaction_control,
|
||||
)
|
||||
self._client = client
|
||||
|
||||
if compaction_control is not None and compaction_control.get("enabled"):
|
||||
warnings.warn(
|
||||
"The 'compaction_control' parameter is deprecated and will be removed in a future version. "
|
||||
"Use server-side compaction instead by passing `edits=[{'type': 'compact_20260112'}]` in your "
|
||||
"the params passed to `tool_runner()`. See https://platform.claude.com/docs/en/build-with-claude/compaction",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
self._iterator = self.__run__()
|
||||
self._last_message: (
|
||||
Callable[[], Coroutine[None, None, ParsedBetaMessage[ResponseFormatT]]]
|
||||
| ParsedBetaMessage[ResponseFormatT]
|
||||
| None
|
||||
) = None
|
||||
|
||||
async def __anext__(self) -> RunnerItemT:
|
||||
return await self._iterator.__anext__()
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[RunnerItemT]:
|
||||
async for item in self._iterator:
|
||||
yield item
|
||||
|
||||
@abstractmethod
|
||||
@asynccontextmanager
|
||||
async def _handle_request(self) -> AsyncIterator[RunnerItemT]:
|
||||
raise NotImplementedError()
|
||||
yield # type: ignore[unreachable]
|
||||
|
||||
async def _check_and_compact(self) -> bool:
|
||||
"""
|
||||
Check token usage and compact messages if threshold exceeded.
|
||||
Returns True if compaction was performed, False otherwise.
|
||||
"""
|
||||
if self._compaction_control is None or not self._compaction_control["enabled"]:
|
||||
return False
|
||||
|
||||
message = await self._get_last_message()
|
||||
tokens_used = 0
|
||||
if message is not None:
|
||||
total_input_tokens = (
|
||||
message.usage.input_tokens
|
||||
+ (message.usage.cache_creation_input_tokens or 0)
|
||||
+ (message.usage.cache_read_input_tokens or 0)
|
||||
)
|
||||
tokens_used = total_input_tokens + message.usage.output_tokens
|
||||
|
||||
threshold = self._compaction_control.get("context_token_threshold", DEFAULT_THRESHOLD)
|
||||
|
||||
if tokens_used < threshold:
|
||||
return False
|
||||
|
||||
# Perform compaction
|
||||
log.info(f"Token usage {tokens_used} has exceeded the threshold of {threshold}. Performing compaction.")
|
||||
|
||||
model = self._compaction_control.get("model", self._params["model"])
|
||||
|
||||
messages = list(self._params["messages"])
|
||||
|
||||
if messages[-1]["role"] == "assistant":
|
||||
# Remove tool_use blocks from the last message to avoid 400 error
|
||||
# (tool_use requires tool_result, which we don't have yet)
|
||||
non_tool_blocks = [
|
||||
block
|
||||
for block in messages[-1]["content"]
|
||||
if isinstance(block, dict) and block.get("type") != "tool_use"
|
||||
]
|
||||
|
||||
if non_tool_blocks:
|
||||
messages[-1]["content"] = non_tool_blocks
|
||||
else:
|
||||
messages.pop()
|
||||
|
||||
messages = [
|
||||
*messages,
|
||||
BetaMessageParam(
|
||||
role="user",
|
||||
content=self._compaction_control.get("summary_prompt", DEFAULT_SUMMARY_PROMPT),
|
||||
),
|
||||
]
|
||||
|
||||
response = await self._client.beta.messages.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=self._params["max_tokens"],
|
||||
extra_headers={"X-Stainless-Helper": "compaction"},
|
||||
)
|
||||
|
||||
log.info(f"Compaction complete. New token usage: {response.usage.output_tokens}")
|
||||
|
||||
first_content = list(response.content)[0]
|
||||
|
||||
if first_content.type != "text":
|
||||
raise ValueError("Compaction response content is not of type 'text'")
|
||||
|
||||
self.set_messages_params(
|
||||
lambda params: {
|
||||
**params,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": first_content.text,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
return True
|
||||
|
||||
async def __run__(self) -> AsyncIterator[RunnerItemT]:
|
||||
while not self._should_stop():
|
||||
async with self._handle_request() as item:
|
||||
yield item
|
||||
message = await self._get_last_message()
|
||||
assert message is not None
|
||||
|
||||
# Update container from response for programmatic tool calling support
|
||||
last_assistant_message = await self._get_last_assistant_message()
|
||||
if last_assistant_message is not None and last_assistant_message.container is not None:
|
||||
self._params["container"] = last_assistant_message.container.id
|
||||
|
||||
self._iteration_count += 1
|
||||
|
||||
# If the compaction was performed, skip tool call generation this iteration
|
||||
if not await self._check_and_compact():
|
||||
response = await self.generate_tool_call_response()
|
||||
if response is None:
|
||||
log.debug("Tool call was not requested, exiting from tool runner loop.")
|
||||
return
|
||||
|
||||
if not self._messages_modified:
|
||||
self.append_messages(message, response)
|
||||
|
||||
self._messages_modified = False
|
||||
self._cached_tool_call_response = None
|
||||
|
||||
async def until_done(self) -> ParsedBetaMessage[ResponseFormatT]:
|
||||
"""
|
||||
Consumes the tool runner stream and returns the last message if it has not been consumed yet.
|
||||
If it has, it simply returns the last message.
|
||||
"""
|
||||
await consume_async_iterator(self)
|
||||
last_message = await self._get_last_message()
|
||||
assert last_message is not None
|
||||
return last_message
|
||||
|
||||
async def generate_tool_call_response(self) -> BetaMessageParam | None:
|
||||
"""Generate a MessageParam by calling tool functions with any tool use blocks from the last message.
|
||||
|
||||
Note the tool call response is cached, repeated calls to this method will return the same response.
|
||||
|
||||
None can be returned if no tool call was applicable.
|
||||
"""
|
||||
if self._cached_tool_call_response is not None:
|
||||
log.debug("Returning cached tool call response.")
|
||||
return self._cached_tool_call_response
|
||||
|
||||
response = await self._generate_tool_call_response()
|
||||
self._cached_tool_call_response = response
|
||||
return response
|
||||
|
||||
async def _get_last_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
|
||||
if callable(self._last_message):
|
||||
return await self._last_message()
|
||||
return self._last_message
|
||||
|
||||
async def _get_last_assistant_message(self) -> ParsedBetaMessage[ResponseFormatT] | None:
|
||||
last_message = await self._get_last_message()
|
||||
if last_message is None or last_message.role != "assistant" or not last_message.content:
|
||||
return None
|
||||
|
||||
return last_message
|
||||
|
||||
async def _get_last_assistant_message_content(self) -> list[ParsedBetaContentBlock[ResponseFormatT]] | None:
|
||||
last_assistant_message = await self._get_last_assistant_message()
|
||||
if last_assistant_message is None:
|
||||
return None
|
||||
|
||||
return last_assistant_message.content
|
||||
|
||||
async def _generate_tool_call_response(self) -> BetaMessageParam | None:
|
||||
content = await self._get_last_assistant_message_content()
|
||||
if not content:
|
||||
return None
|
||||
|
||||
tool_use_blocks = [block for block in content if block.type == "tool_use"]
|
||||
if not tool_use_blocks:
|
||||
return None
|
||||
|
||||
results: list[BetaToolResultBlockParam] = []
|
||||
|
||||
for tool_use in tool_use_blocks:
|
||||
tool = self._tools_by_name.get(tool_use.name)
|
||||
if tool is None:
|
||||
warnings.warn(
|
||||
f"Tool '{tool_use.name}' not found in tool runner. "
|
||||
f"Available tools: {list(self._tools_by_name.keys())}. "
|
||||
f"If using a raw tool definition, handle the tool call manually and use `append_messages()` to add the result. "
|
||||
f"Otherwise, pass the tool using `beta_async_tool(func)` or a `@beta_async_tool` decorated function.",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": f"Error: Tool '{tool_use.name}' not found",
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
result = await tool.call(tool_use.input)
|
||||
results.append({"type": "tool_result", "tool_use_id": tool_use.id, "content": result})
|
||||
except ToolError as exc:
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": exc.content,
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
log.exception(f"Error occurred while calling tool: {tool.name}", exc_info=exc)
|
||||
results.append(
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": tool_use.id,
|
||||
"content": repr(exc),
|
||||
"is_error": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {"role": "user", "content": results}
|
||||
|
||||
|
||||
class BetaAsyncToolRunner(BaseAsyncToolRunner[ParsedBetaMessage[ResponseFormatT], ResponseFormatT]):
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def _handle_request(self) -> AsyncIterator[ParsedBetaMessage[ResponseFormatT]]:
|
||||
message = await self._client.beta.messages.parse(**self._params, **self._options)
|
||||
self._last_message = message
|
||||
yield message
|
||||
|
||||
|
||||
class BetaAsyncStreamingToolRunner(BaseAsyncToolRunner[BetaAsyncMessageStream[ResponseFormatT], ResponseFormatT]):
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def _handle_request(self) -> AsyncIterator[BetaAsyncMessageStream[ResponseFormatT]]:
|
||||
async with self._client.beta.messages.stream(**self._params, **self._options) as stream:
|
||||
self._last_message = stream.get_final_message
|
||||
yield stream
|
||||
442
venv/lib/python3.12/site-packages/anthropic/lib/tools/mcp.py
Normal file
442
venv/lib/python3.12/site-packages/anthropic/lib/tools/mcp.py
Normal file
@@ -0,0 +1,442 @@
|
||||
"""Helpers for integrating MCP (Model Context Protocol) SDK types with the Anthropic SDK.
|
||||
|
||||
These helpers reduce boilerplate when converting between MCP types and Anthropic API types.
|
||||
|
||||
Usage::
|
||||
|
||||
from anthropic.lib.tools.mcp import mcp_tool, async_mcp_tool, mcp_message
|
||||
|
||||
This module requires the ``mcp`` package to be installed.
|
||||
"""
|
||||
# pyright: reportUnknownArgumentType=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportMissingImports=false, reportUnknownParameterType=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import base64
|
||||
from typing import Any, Iterable
|
||||
from urllib.parse import urlparse
|
||||
from typing_extensions import Literal
|
||||
|
||||
try:
|
||||
from mcp.types import ( # type: ignore[import-not-found]
|
||||
Tool,
|
||||
TextContent,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
PromptMessage,
|
||||
CallToolResult,
|
||||
EmbeddedResource,
|
||||
ReadResourceResult,
|
||||
BlobResourceContents,
|
||||
TextResourceContents,
|
||||
)
|
||||
from mcp.client.session import ClientSession # type: ignore[import-not-found]
|
||||
except ImportError as _err:
|
||||
raise ImportError(
|
||||
"The `mcp` package is required to use MCP helpers. Install it with: pip install anthropic[mcp]. Requires Python 3.10 or higher."
|
||||
) from _err
|
||||
|
||||
from ...types.beta import (
|
||||
BetaBase64PDFSourceParam,
|
||||
BetaPlainTextSourceParam,
|
||||
BetaBase64ImageSourceParam,
|
||||
BetaCacheControlEphemeralParam,
|
||||
)
|
||||
from ._beta_functions import (
|
||||
ToolError,
|
||||
BetaFunctionTool,
|
||||
BetaAsyncFunctionTool,
|
||||
BetaFunctionToolResultType,
|
||||
beta_tool,
|
||||
beta_async_tool,
|
||||
)
|
||||
from .._stainless_helpers import tag_helper
|
||||
from ...types.beta.beta_tool_result_block_param import Content as BetaContent
|
||||
|
||||
__all__ = [
|
||||
"mcp_tool",
|
||||
"async_mcp_tool",
|
||||
"mcp_content",
|
||||
"mcp_message",
|
||||
"mcp_resource_to_content",
|
||||
"mcp_resource_to_file",
|
||||
"UnsupportedMCPValueError",
|
||||
]
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Supported MIME types
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
_SUPPORTED_IMAGE_TYPES = frozenset({"image/jpeg", "image/png", "image/gif", "image/webp"})
|
||||
|
||||
|
||||
class _TaggedDict(dict): # type: ignore[type-arg]
|
||||
"""A dict subclass that can carry a ``_stainless_helper`` attribute.
|
||||
|
||||
Behaves identically to a regular dict for serialization and isinstance checks,
|
||||
but allows attaching tracking metadata that won't appear in JSON output.
|
||||
"""
|
||||
|
||||
|
||||
class _TaggedTuple(tuple): # type: ignore[type-arg]
|
||||
"""A tuple subclass that can carry a ``_stainless_helper`` attribute."""
|
||||
|
||||
|
||||
def _is_supported_image_type(mime_type: str) -> bool:
|
||||
return mime_type in _SUPPORTED_IMAGE_TYPES
|
||||
|
||||
|
||||
def _is_supported_resource_mime_type(mime_type: str | None) -> bool:
|
||||
return (
|
||||
mime_type is None
|
||||
or mime_type.startswith("text/")
|
||||
or mime_type == "application/pdf"
|
||||
or _is_supported_image_type(mime_type)
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Errors
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
class UnsupportedMCPValueError(Exception):
|
||||
"""Raised when an MCP value cannot be converted to a format supported by the Claude API."""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Content conversion
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def mcp_content(
|
||||
content: ContentBlock,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
) -> BetaContent:
|
||||
"""Convert a single MCP content block to an Anthropic content block.
|
||||
|
||||
Handles text, image, and embedded resource content types.
|
||||
Raises :class:`UnsupportedMCPValueError` for audio and resource_link types.
|
||||
"""
|
||||
if isinstance(content, TextContent):
|
||||
block = _TaggedDict({"type": "text", "text": content.text})
|
||||
if cache_control is not None:
|
||||
block["cache_control"] = cache_control
|
||||
tag_helper(block, "mcp_content")
|
||||
return block # type: ignore[return-value]
|
||||
|
||||
if isinstance(content, ImageContent):
|
||||
if not _is_supported_image_type(content.mimeType):
|
||||
raise UnsupportedMCPValueError(f"Unsupported image MIME type: {content.mimeType}")
|
||||
image_block = _TaggedDict(
|
||||
{
|
||||
"type": "image",
|
||||
"source": BetaBase64ImageSourceParam(
|
||||
type="base64",
|
||||
data=content.data,
|
||||
media_type=content.mimeType, # type: ignore[typeddict-item]
|
||||
),
|
||||
}
|
||||
)
|
||||
if cache_control is not None:
|
||||
image_block["cache_control"] = cache_control
|
||||
tag_helper(image_block, "mcp_content")
|
||||
return image_block # type: ignore[return-value]
|
||||
|
||||
if isinstance(content, EmbeddedResource):
|
||||
return _resource_contents_to_block(content.resource, cache_control=cache_control)
|
||||
|
||||
# audio, resource_link, or unknown
|
||||
content_type = getattr(content, "type", type(content).__name__)
|
||||
raise UnsupportedMCPValueError(f"Unsupported MCP content type: {content_type}")
|
||||
|
||||
|
||||
def _resource_contents_to_block(
|
||||
resource: TextResourceContents | BlobResourceContents,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
) -> BetaContent:
|
||||
"""Convert MCP resource contents to an Anthropic content block."""
|
||||
mime_type = resource.mimeType
|
||||
|
||||
# Images
|
||||
if mime_type is not None and _is_supported_image_type(mime_type):
|
||||
if not isinstance(resource, BlobResourceContents):
|
||||
raise UnsupportedMCPValueError(f"Image resource must have blob data, not text. URI: {resource.uri}")
|
||||
image_block = _TaggedDict(
|
||||
{
|
||||
"type": "image",
|
||||
"source": BetaBase64ImageSourceParam(
|
||||
type="base64",
|
||||
data=resource.blob,
|
||||
media_type=mime_type, # type: ignore[typeddict-item]
|
||||
),
|
||||
}
|
||||
)
|
||||
if cache_control is not None:
|
||||
image_block["cache_control"] = cache_control
|
||||
tag_helper(image_block, "mcp_resource_to_content")
|
||||
return image_block # type: ignore[return-value]
|
||||
|
||||
# PDFs
|
||||
if mime_type == "application/pdf":
|
||||
if not isinstance(resource, BlobResourceContents):
|
||||
raise UnsupportedMCPValueError(f"PDF resource must have blob data, not text. URI: {resource.uri}")
|
||||
pdf_block = _TaggedDict(
|
||||
{
|
||||
"type": "document",
|
||||
"source": BetaBase64PDFSourceParam(
|
||||
type="base64",
|
||||
data=resource.blob,
|
||||
media_type="application/pdf",
|
||||
),
|
||||
}
|
||||
)
|
||||
if cache_control is not None:
|
||||
pdf_block["cache_control"] = cache_control
|
||||
tag_helper(pdf_block, "mcp_resource_to_content")
|
||||
return pdf_block # type: ignore[return-value]
|
||||
|
||||
# Text (text/*, or no MIME type)
|
||||
if mime_type is None or mime_type.startswith("text/"):
|
||||
if isinstance(resource, TextResourceContents):
|
||||
data = resource.text
|
||||
else:
|
||||
data = base64.b64decode(resource.blob).decode("utf-8")
|
||||
text_block = _TaggedDict(
|
||||
{
|
||||
"type": "document",
|
||||
"source": BetaPlainTextSourceParam(
|
||||
type="text",
|
||||
data=data,
|
||||
media_type="text/plain",
|
||||
),
|
||||
}
|
||||
)
|
||||
if cache_control is not None:
|
||||
text_block["cache_control"] = cache_control
|
||||
tag_helper(text_block, "mcp_resource_to_content")
|
||||
return text_block # type: ignore[return-value]
|
||||
|
||||
raise UnsupportedMCPValueError(f'Unsupported MIME type "{mime_type}" for resource: {resource.uri}')
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Message conversion
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def mcp_message(
|
||||
message: PromptMessage,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Convert an MCP prompt message to an Anthropic ``BetaMessageParam``."""
|
||||
result = _TaggedDict(
|
||||
{
|
||||
"role": message.role,
|
||||
"content": [mcp_content(message.content, cache_control=cache_control)],
|
||||
}
|
||||
)
|
||||
tag_helper(result, "mcp_message")
|
||||
return result
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Resource conversion
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def mcp_resource_to_content(
|
||||
result: ReadResourceResult,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
) -> BetaContent:
|
||||
"""Convert MCP resource contents to an Anthropic content block.
|
||||
|
||||
Finds the first resource with a supported MIME type from the result's
|
||||
``contents`` list.
|
||||
"""
|
||||
if not result.contents:
|
||||
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
|
||||
|
||||
supported = next(
|
||||
(c for c in result.contents if _is_supported_resource_mime_type(c.mimeType)),
|
||||
None,
|
||||
)
|
||||
if supported is None:
|
||||
mime_types = [c.mimeType for c in result.contents if c.mimeType is not None]
|
||||
raise UnsupportedMCPValueError(
|
||||
f"No supported MIME type found in resource contents. Available: {', '.join(mime_types)}"
|
||||
)
|
||||
|
||||
return _resource_contents_to_block(supported, cache_control=cache_control)
|
||||
|
||||
|
||||
def mcp_resource_to_file(
|
||||
result: ReadResourceResult,
|
||||
) -> tuple[str | None, bytes, str | None]:
|
||||
"""Convert MCP resource contents to a file tuple for ``files.upload()``.
|
||||
|
||||
Returns a ``(filename, content_bytes, mime_type)`` tuple compatible with
|
||||
the SDK's ``FileTypes``.
|
||||
"""
|
||||
if not result.contents:
|
||||
raise UnsupportedMCPValueError("Resource contents array must contain at least one item")
|
||||
|
||||
resource = result.contents[0]
|
||||
uri_str = str(resource.uri)
|
||||
|
||||
# Extract filename from URI
|
||||
path = urlparse(uri_str).path
|
||||
name = path.rsplit("/", 1)[-1] if path else None
|
||||
|
||||
# Get bytes
|
||||
if isinstance(resource, BlobResourceContents):
|
||||
content_bytes = base64.b64decode(resource.blob)
|
||||
else:
|
||||
content_bytes = resource.text.encode("utf-8")
|
||||
|
||||
file_tuple = _TaggedTuple((name, content_bytes, resource.mimeType))
|
||||
tag_helper(file_tuple, "mcp_resource_to_file")
|
||||
return file_tuple
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Tool result conversion (used by tool call handlers)
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _convert_tool_result(result: CallToolResult) -> BetaFunctionToolResultType:
|
||||
"""Convert MCP ``CallToolResult`` to a value suitable for returning from ``call()``."""
|
||||
if result.isError:
|
||||
raise ToolError([mcp_content(item) for item in result.content])
|
||||
|
||||
# If content is empty but structuredContent is present, JSON-encode it
|
||||
if not result.content and result.structuredContent is not None:
|
||||
return json.dumps(result.structuredContent)
|
||||
|
||||
return [mcp_content(item) for item in result.content]
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Public factory functions
|
||||
# -----------------------------------------------------------------------
|
||||
|
||||
|
||||
def mcp_tool(
|
||||
tool: Tool,
|
||||
client: ClientSession,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaFunctionTool[Any]:
|
||||
"""Convert an MCP tool to a sync runnable tool for ``tool_runner()``.
|
||||
|
||||
Example::
|
||||
|
||||
from anthropic.lib.tools.mcp import mcp_tool
|
||||
|
||||
tools_result = await mcp_client.list_tools()
|
||||
runner = client.beta.messages.tool_runner(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
tools=[mcp_tool(t, mcp_client) for t in tools_result.tools],
|
||||
messages=[{"role": "user", "content": "Use the available tools"}],
|
||||
)
|
||||
|
||||
Args:
|
||||
tool: An MCP tool definition from ``client.list_tools()``.
|
||||
client: The MCP ``ClientSession`` used to call the tool.
|
||||
cache_control: Cache control configuration.
|
||||
defer_loading: If true, tool will not be included in initial system prompt.
|
||||
allowed_callers: Which callers may use this tool.
|
||||
eager_input_streaming: Enable eager input streaming for this tool.
|
||||
input_examples: Example inputs for the tool.
|
||||
strict: When true, guarantees schema validation on tool names and inputs.
|
||||
"""
|
||||
import anyio.from_thread
|
||||
|
||||
tool_name = tool.name
|
||||
|
||||
def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
|
||||
result = anyio.from_thread.run(client.call_tool, tool_name, kwargs)
|
||||
return _convert_tool_result(result)
|
||||
|
||||
result = beta_tool(
|
||||
call_mcp,
|
||||
name=tool_name,
|
||||
description=tool.description,
|
||||
input_schema=tool.inputSchema,
|
||||
cache_control=cache_control,
|
||||
defer_loading=defer_loading,
|
||||
allowed_callers=allowed_callers,
|
||||
eager_input_streaming=eager_input_streaming,
|
||||
input_examples=input_examples,
|
||||
strict=strict,
|
||||
)
|
||||
tag_helper(result, "mcp_tool")
|
||||
return result
|
||||
|
||||
|
||||
def async_mcp_tool(
|
||||
tool: Tool,
|
||||
client: ClientSession,
|
||||
*,
|
||||
cache_control: BetaCacheControlEphemeralParam | None = None,
|
||||
defer_loading: bool | None = None,
|
||||
allowed_callers: list[Literal["direct", "code_execution_20250825", "code_execution_20260120"]] | None = None,
|
||||
eager_input_streaming: bool | None = None,
|
||||
input_examples: Iterable[dict[str, object]] | None = None,
|
||||
strict: bool | None = None,
|
||||
) -> BetaAsyncFunctionTool[Any]:
|
||||
"""Convert an MCP tool to an async runnable tool for ``tool_runner()``.
|
||||
|
||||
Example::
|
||||
|
||||
from anthropic.lib.tools.mcp import async_mcp_tool
|
||||
|
||||
tools_result = await mcp_client.list_tools()
|
||||
runner = await client.beta.messages.tool_runner(
|
||||
model="claude-sonnet-4-20250514",
|
||||
max_tokens=1024,
|
||||
tools=[async_mcp_tool(t, mcp_client) for t in tools_result.tools],
|
||||
messages=[{"role": "user", "content": "Use the available tools"}],
|
||||
)
|
||||
|
||||
Args:
|
||||
tool: An MCP tool definition from ``client.list_tools()``.
|
||||
client: The MCP ``ClientSession`` used to call the tool.
|
||||
cache_control: Cache control configuration.
|
||||
defer_loading: If true, tool will not be included in initial system prompt.
|
||||
allowed_callers: Which callers may use this tool.
|
||||
eager_input_streaming: Enable eager input streaming for this tool.
|
||||
input_examples: Example inputs for the tool.
|
||||
strict: When true, guarantees schema validation on tool names and inputs.
|
||||
"""
|
||||
tool_name = tool.name
|
||||
|
||||
async def call_mcp(**kwargs: Any) -> BetaFunctionToolResultType:
|
||||
result = await client.call_tool(name=tool_name, arguments=kwargs)
|
||||
return _convert_tool_result(result)
|
||||
|
||||
result = beta_async_tool(
|
||||
call_mcp,
|
||||
name=tool_name,
|
||||
description=tool.description,
|
||||
input_schema=tool.inputSchema,
|
||||
cache_control=cache_control,
|
||||
defer_loading=defer_loading,
|
||||
allowed_callers=allowed_callers,
|
||||
eager_input_streaming=eager_input_streaming,
|
||||
input_examples=input_examples,
|
||||
strict=strict,
|
||||
)
|
||||
tag_helper(result, "mcp_tool")
|
||||
return result
|
||||
Reference in New Issue
Block a user