agenttool-sdk 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agenttool_sdk-0.1.0/PKG-INFO +31 -0
- agenttool_sdk-0.1.0/README.md +20 -0
- agenttool_sdk-0.1.0/pyproject.toml +22 -0
- agenttool_sdk-0.1.0/src/agenttool/__init__.py +17 -0
- agenttool_sdk-0.1.0/src/agenttool/client.py +85 -0
- agenttool_sdk-0.1.0/src/agenttool/exceptions.py +22 -0
- agenttool_sdk-0.1.0/src/agenttool/memory.py +132 -0
- agenttool_sdk-0.1.0/src/agenttool/models.py +103 -0
- agenttool_sdk-0.1.0/src/agenttool/tools.py +87 -0
- agenttool_sdk-0.1.0/tests/__init__.py +1 -0
- agenttool_sdk-0.1.0/tests/test_client.py +251 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agenttool-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for agenttool.dev — memory and tools for AI agents
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# agenttool
|
|
13
|
+
|
|
14
|
+
Python SDK for [agenttool.dev](https://agenttool.dev) — memory and tools for AI agents.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install agenttool
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from agenttool import AgentTool
|
|
22
|
+
|
|
23
|
+
at = AgentTool() # reads AT_API_KEY from env
|
|
24
|
+
at.memory.store("learned something new")
|
|
25
|
+
results = at.memory.search("what did I learn?")
|
|
26
|
+
hits = at.tools.search("latest AI news")
|
|
27
|
+
page = at.tools.scrape("https://example.com")
|
|
28
|
+
out = at.tools.execute("print(1 + 1)")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Set your key: `export AT_API_KEY=your-key-here`
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# agenttool
|
|
2
|
+
|
|
3
|
+
Python SDK for [agenttool.dev](https://agenttool.dev) — memory and tools for AI agents.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install agenttool
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from agenttool import AgentTool
|
|
11
|
+
|
|
12
|
+
at = AgentTool() # reads AT_API_KEY from env
|
|
13
|
+
at.memory.store("learned something new")
|
|
14
|
+
results = at.memory.search("what did I learn?")
|
|
15
|
+
hits = at.tools.search("latest AI news")
|
|
16
|
+
page = at.tools.scrape("https://example.com")
|
|
17
|
+
out = at.tools.execute("print(1 + 1)")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Set your key: `export AT_API_KEY=your-key-here`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "agenttool-sdk"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for agenttool.dev — memory and tools for AI agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = [
|
|
18
|
+
"pytest>=7.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/agenttool"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""AgentTool SDK — memory and tools for AI agents."""
|
|
2
|
+
|
|
3
|
+
from .client import AgentTool
|
|
4
|
+
from .exceptions import AgentToolError
|
|
5
|
+
from .models import ExecuteResult, Memory, ScrapeResult, SearchResult, UsageStats
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentTool",
|
|
9
|
+
"AgentToolError",
|
|
10
|
+
"ExecuteResult",
|
|
11
|
+
"Memory",
|
|
12
|
+
"ScrapeResult",
|
|
13
|
+
"SearchResult",
|
|
14
|
+
"UsageStats",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Main AgentTool client — the single entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .exceptions import AgentToolError
|
|
11
|
+
from .memory import MemoryClient
|
|
12
|
+
from .tools import ToolsClient
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentTool:
|
|
16
|
+
"""Unified client for the agenttool.dev platform.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
from agenttool import AgentTool
|
|
21
|
+
|
|
22
|
+
at = AgentTool() # reads AT_API_KEY from env
|
|
23
|
+
at.memory.store("just a string") # store a memory
|
|
24
|
+
results = at.memory.search("what I said") # semantic search
|
|
25
|
+
hits = at.tools.search("latest AI news") # web search
|
|
26
|
+
page = at.tools.scrape("https://x.com") # scrape a URL
|
|
27
|
+
out = at.tools.execute("print(42)") # run sandboxed code
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
api_key: API key. Falls back to ``AT_API_KEY`` env var.
|
|
31
|
+
base_url: Override the API base URL.
|
|
32
|
+
timeout: Request timeout in seconds.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
api_key: Optional[str] = None,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = "https://api.agenttool.dev",
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
) -> None:
|
|
42
|
+
resolved_key = api_key or os.environ.get("AT_API_KEY")
|
|
43
|
+
if not resolved_key:
|
|
44
|
+
raise AgentToolError(
|
|
45
|
+
"No API key provided.",
|
|
46
|
+
hint="Pass api_key= or set the AT_API_KEY environment variable.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
self._http = httpx.Client(
|
|
50
|
+
headers={
|
|
51
|
+
"Authorization": f"Bearer {resolved_key}",
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
},
|
|
54
|
+
timeout=timeout,
|
|
55
|
+
)
|
|
56
|
+
self._base_url = base_url.rstrip("/")
|
|
57
|
+
self._memory: Optional[MemoryClient] = None
|
|
58
|
+
self._tools: Optional[ToolsClient] = None
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def memory(self) -> MemoryClient:
|
|
62
|
+
"""Access the Memory API."""
|
|
63
|
+
if self._memory is None:
|
|
64
|
+
self._memory = MemoryClient(self._http, self._base_url)
|
|
65
|
+
return self._memory
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def tools(self) -> ToolsClient:
|
|
69
|
+
"""Access the Tools API."""
|
|
70
|
+
if self._tools is None:
|
|
71
|
+
self._tools = ToolsClient(self._http, self._base_url)
|
|
72
|
+
return self._tools
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
"""Close the underlying HTTP connection."""
|
|
76
|
+
self._http.close()
|
|
77
|
+
|
|
78
|
+
def __enter__(self) -> AgentTool:
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def __exit__(self, *args: object) -> None:
|
|
82
|
+
self.close()
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return f"AgentTool(base_url={self._base_url!r})"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exceptions for the AgentTool SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentToolError(Exception):
|
|
7
|
+
"""Base error for all AgentTool SDK operations.
|
|
8
|
+
|
|
9
|
+
Attributes:
|
|
10
|
+
message: Human-readable error description.
|
|
11
|
+
hint: Actionable suggestion for fixing the error.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, message: str, *, hint: str | None = None) -> None:
|
|
15
|
+
self.message = message
|
|
16
|
+
self.hint = hint
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
if self.hint:
|
|
21
|
+
return f"{self.message} (hint: {self.hint})"
|
|
22
|
+
return self.message
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Memory client for agent-memory API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AgentToolError
|
|
10
|
+
from .models import Memory, UsageStats
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MemoryClient:
|
|
17
|
+
"""Client for the agent-memory API.
|
|
18
|
+
|
|
19
|
+
Usage::
|
|
20
|
+
|
|
21
|
+
at = AgentTool()
|
|
22
|
+
at.memory.store("just a string")
|
|
23
|
+
results = at.memory.search("what did I learn?")
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, http: httpx.Client, base_url: str) -> None:
|
|
27
|
+
self._http = http
|
|
28
|
+
self._base = base_url.rstrip("/")
|
|
29
|
+
|
|
30
|
+
def _url(self, path: str) -> str:
|
|
31
|
+
return f"{self._base}{path}"
|
|
32
|
+
|
|
33
|
+
def store(
|
|
34
|
+
self,
|
|
35
|
+
content: str,
|
|
36
|
+
*,
|
|
37
|
+
type: str = "semantic",
|
|
38
|
+
agent_id: Optional[str] = None,
|
|
39
|
+
key: Optional[str] = None,
|
|
40
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
41
|
+
importance: float = 0.5,
|
|
42
|
+
) -> Memory:
|
|
43
|
+
"""Store a memory. Only ``content`` is required.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
content: The memory content string.
|
|
47
|
+
type: One of semantic, episodic, procedural, working.
|
|
48
|
+
agent_id: Optional agent identifier.
|
|
49
|
+
key: Optional dedup/lookup key.
|
|
50
|
+
metadata: Arbitrary metadata dict.
|
|
51
|
+
importance: 0.0–1.0 importance score.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
The created Memory object.
|
|
55
|
+
"""
|
|
56
|
+
body: Dict[str, Any] = {"content": content, "type": type, "importance": importance}
|
|
57
|
+
if agent_id is not None:
|
|
58
|
+
body["agent_id"] = agent_id
|
|
59
|
+
if key is not None:
|
|
60
|
+
body["key"] = key
|
|
61
|
+
if metadata is not None:
|
|
62
|
+
body["metadata"] = metadata
|
|
63
|
+
|
|
64
|
+
resp = self._http.post(self._url("/v1/memories"), json=body)
|
|
65
|
+
self._check(resp)
|
|
66
|
+
return Memory.from_dict(resp.json())
|
|
67
|
+
|
|
68
|
+
def search(
|
|
69
|
+
self,
|
|
70
|
+
query: str,
|
|
71
|
+
*,
|
|
72
|
+
limit: int = 10,
|
|
73
|
+
type: Optional[str] = None,
|
|
74
|
+
agent_id: Optional[str] = None,
|
|
75
|
+
) -> List[Memory]:
|
|
76
|
+
"""Semantic search over stored memories.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
query: Natural-language search query.
|
|
80
|
+
limit: Max results to return.
|
|
81
|
+
type: Filter by memory type.
|
|
82
|
+
agent_id: Filter by agent.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of matching Memory objects.
|
|
86
|
+
"""
|
|
87
|
+
body: Dict[str, Any] = {"query": query, "limit": limit}
|
|
88
|
+
if type is not None:
|
|
89
|
+
body["type"] = type
|
|
90
|
+
if agent_id is not None:
|
|
91
|
+
body["agent_id"] = agent_id
|
|
92
|
+
|
|
93
|
+
resp = self._http.post(self._url("/v1/memories/search"), json=body)
|
|
94
|
+
self._check(resp)
|
|
95
|
+
data = resp.json()
|
|
96
|
+
results = data if isinstance(data, list) else data.get("results", [])
|
|
97
|
+
return [Memory.from_dict(m) for m in results]
|
|
98
|
+
|
|
99
|
+
def get(self, memory_id: str) -> Memory:
|
|
100
|
+
"""Retrieve a single memory by ID.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
memory_id: The memory's unique identifier.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The Memory object.
|
|
107
|
+
"""
|
|
108
|
+
resp = self._http.get(self._url(f"/v1/memories/{memory_id}"))
|
|
109
|
+
self._check(resp)
|
|
110
|
+
return Memory.from_dict(resp.json())
|
|
111
|
+
|
|
112
|
+
def usage(self) -> UsageStats:
|
|
113
|
+
"""Get usage statistics.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
UsageStats with current counters.
|
|
117
|
+
"""
|
|
118
|
+
resp = self._http.get(self._url("/v1/usage"))
|
|
119
|
+
self._check(resp)
|
|
120
|
+
return UsageStats.from_dict(resp.json())
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _check(resp: httpx.Response) -> None:
|
|
124
|
+
if resp.status_code >= 400:
|
|
125
|
+
try:
|
|
126
|
+
detail = resp.json().get("detail", resp.text)
|
|
127
|
+
except Exception:
|
|
128
|
+
detail = resp.text
|
|
129
|
+
raise AgentToolError(
|
|
130
|
+
f"Memory API error ({resp.status_code}): {detail}",
|
|
131
|
+
hint="Check your API key and request parameters.",
|
|
132
|
+
)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Data models for the AgentTool SDK — plain dataclasses, no pydantic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Memory:
|
|
11
|
+
"""A stored memory."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
content: str
|
|
15
|
+
type: str = "semantic"
|
|
16
|
+
agent_id: Optional[str] = None
|
|
17
|
+
key: Optional[str] = None
|
|
18
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
importance: float = 0.5
|
|
20
|
+
created_at: Optional[str] = None
|
|
21
|
+
updated_at: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_dict(cls, data: Dict[str, Any]) -> Memory:
|
|
25
|
+
return cls(
|
|
26
|
+
id=data.get("id", ""),
|
|
27
|
+
content=data.get("content", ""),
|
|
28
|
+
type=data.get("type", "semantic"),
|
|
29
|
+
agent_id=data.get("agent_id"),
|
|
30
|
+
key=data.get("key"),
|
|
31
|
+
metadata=data.get("metadata", {}),
|
|
32
|
+
importance=data.get("importance", 0.5),
|
|
33
|
+
created_at=data.get("created_at"),
|
|
34
|
+
updated_at=data.get("updated_at"),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class SearchResult:
|
|
40
|
+
"""A web search result."""
|
|
41
|
+
|
|
42
|
+
title: str
|
|
43
|
+
url: str
|
|
44
|
+
snippet: str = ""
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: Dict[str, Any]) -> SearchResult:
|
|
48
|
+
return cls(
|
|
49
|
+
title=data.get("title", ""),
|
|
50
|
+
url=data.get("url", ""),
|
|
51
|
+
snippet=data.get("snippet", ""),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ScrapeResult:
|
|
57
|
+
"""Result of scraping a URL."""
|
|
58
|
+
|
|
59
|
+
url: str
|
|
60
|
+
content: str
|
|
61
|
+
status_code: int = 200
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, data: Dict[str, Any]) -> ScrapeResult:
|
|
65
|
+
return cls(
|
|
66
|
+
url=data.get("url", ""),
|
|
67
|
+
content=data.get("content", ""),
|
|
68
|
+
status_code=data.get("status_code", 200),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ExecuteResult:
|
|
74
|
+
"""Result of sandboxed code execution."""
|
|
75
|
+
|
|
76
|
+
output: str
|
|
77
|
+
error: str = ""
|
|
78
|
+
exit_code: int = 0
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, data: Dict[str, Any]) -> ExecuteResult:
|
|
82
|
+
return cls(
|
|
83
|
+
output=data.get("output", ""),
|
|
84
|
+
error=data.get("error", ""),
|
|
85
|
+
exit_code=data.get("exit_code", 0),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class UsageStats:
|
|
91
|
+
"""API usage statistics."""
|
|
92
|
+
|
|
93
|
+
memories_stored: int = 0
|
|
94
|
+
searches_performed: int = 0
|
|
95
|
+
api_calls: int = 0
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def from_dict(cls, data: Dict[str, Any]) -> UsageStats:
|
|
99
|
+
return cls(
|
|
100
|
+
memories_stored=data.get("memories_stored", 0),
|
|
101
|
+
searches_performed=data.get("searches_performed", 0),
|
|
102
|
+
api_calls=data.get("api_calls", 0),
|
|
103
|
+
)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Tools client for agent-tools API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .exceptions import AgentToolError
|
|
10
|
+
from .models import ExecuteResult, ScrapeResult, SearchResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ToolsClient:
|
|
14
|
+
"""Client for the agent-tools API.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
at = AgentTool()
|
|
19
|
+
results = at.tools.search("latest AI news")
|
|
20
|
+
page = at.tools.scrape("https://example.com")
|
|
21
|
+
out = at.tools.execute("print(1+1)")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, http: httpx.Client, base_url: str) -> None:
|
|
25
|
+
self._http = http
|
|
26
|
+
self._base = base_url.rstrip("/")
|
|
27
|
+
|
|
28
|
+
def _url(self, path: str) -> str:
|
|
29
|
+
return f"{self._base}{path}"
|
|
30
|
+
|
|
31
|
+
def search(self, query: str, *, num_results: int = 5) -> List[SearchResult]:
|
|
32
|
+
"""Web search.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
query: Search query string.
|
|
36
|
+
num_results: Number of results to return.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of SearchResult objects.
|
|
40
|
+
"""
|
|
41
|
+
body: Dict[str, Any] = {"query": query, "num_results": num_results}
|
|
42
|
+
resp = self._http.post(self._url("/v1/search"), json=body)
|
|
43
|
+
self._check(resp)
|
|
44
|
+
data = resp.json()
|
|
45
|
+
results = data if isinstance(data, list) else data.get("results", [])
|
|
46
|
+
return [SearchResult.from_dict(r) for r in results]
|
|
47
|
+
|
|
48
|
+
def scrape(self, url: str) -> ScrapeResult:
|
|
49
|
+
"""Scrape a URL and return its content.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
url: The URL to scrape.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
ScrapeResult with the page content.
|
|
56
|
+
"""
|
|
57
|
+
body: Dict[str, Any] = {"url": url}
|
|
58
|
+
resp = self._http.post(self._url("/v1/scrape"), json=body)
|
|
59
|
+
self._check(resp)
|
|
60
|
+
return ScrapeResult.from_dict(resp.json())
|
|
61
|
+
|
|
62
|
+
def execute(self, code: str, *, language: str = "python") -> ExecuteResult:
|
|
63
|
+
"""Execute code in a sandbox.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
code: Source code to execute.
|
|
67
|
+
language: "python" or "javascript".
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
ExecuteResult with output, error, and exit code.
|
|
71
|
+
"""
|
|
72
|
+
body: Dict[str, Any] = {"code": code, "language": language}
|
|
73
|
+
resp = self._http.post(self._url("/v1/execute"), json=body)
|
|
74
|
+
self._check(resp)
|
|
75
|
+
return ExecuteResult.from_dict(resp.json())
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _check(resp: httpx.Response) -> None:
|
|
79
|
+
if resp.status_code >= 400:
|
|
80
|
+
try:
|
|
81
|
+
detail = resp.json().get("detail", resp.text)
|
|
82
|
+
except Exception:
|
|
83
|
+
detail = resp.text
|
|
84
|
+
raise AgentToolError(
|
|
85
|
+
f"Tools API error ({resp.status_code}): {detail}",
|
|
86
|
+
hint="Check your API key and request parameters.",
|
|
87
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# tests
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Unit tests for the AgentTool SDK — all HTTP mocked, no network needed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from agenttool import AgentTool, AgentToolError, Memory, SearchResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Helpers
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def _mock_response(status_code: int = 200, json_data: object = None, text: str = "") -> MagicMock:
|
|
19
|
+
"""Create a fake httpx.Response."""
|
|
20
|
+
resp = MagicMock(spec=httpx.Response)
|
|
21
|
+
resp.status_code = status_code
|
|
22
|
+
resp.json.return_value = json_data if json_data is not None else {}
|
|
23
|
+
resp.text = text or ""
|
|
24
|
+
return resp
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Client init
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
class TestClientInit:
|
|
32
|
+
def test_reads_env_var(self) -> None:
|
|
33
|
+
with patch.dict(os.environ, {"AT_API_KEY": "test-key-123"}):
|
|
34
|
+
at = AgentTool()
|
|
35
|
+
assert repr(at) == "AgentTool(base_url='https://api.agenttool.dev')"
|
|
36
|
+
at.close()
|
|
37
|
+
|
|
38
|
+
def test_explicit_key_overrides_env(self) -> None:
|
|
39
|
+
with patch.dict(os.environ, {"AT_API_KEY": "env-key"}):
|
|
40
|
+
at = AgentTool(api_key="explicit-key")
|
|
41
|
+
# Check the header was set with the explicit key
|
|
42
|
+
assert at._http.headers["authorization"] == "Bearer explicit-key"
|
|
43
|
+
at.close()
|
|
44
|
+
|
|
45
|
+
def test_missing_key_raises(self) -> None:
|
|
46
|
+
with patch.dict(os.environ, {}, clear=True):
|
|
47
|
+
# Also need to remove AT_API_KEY if it exists
|
|
48
|
+
env = os.environ.copy()
|
|
49
|
+
env.pop("AT_API_KEY", None)
|
|
50
|
+
with patch.dict(os.environ, env, clear=True):
|
|
51
|
+
with pytest.raises(AgentToolError) as exc_info:
|
|
52
|
+
AgentTool()
|
|
53
|
+
assert "No API key" in exc_info.value.message
|
|
54
|
+
assert exc_info.value.hint is not None
|
|
55
|
+
|
|
56
|
+
def test_context_manager(self) -> None:
|
|
57
|
+
with patch.dict(os.environ, {"AT_API_KEY": "k"}):
|
|
58
|
+
with AgentTool() as at:
|
|
59
|
+
assert at is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Memory
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
class TestMemory:
|
|
67
|
+
@pytest.fixture()
|
|
68
|
+
def at(self) -> AgentTool:
|
|
69
|
+
with patch.dict(os.environ, {"AT_API_KEY": "test-key"}):
|
|
70
|
+
client = AgentTool()
|
|
71
|
+
return client
|
|
72
|
+
|
|
73
|
+
def test_store_minimal(self, at: AgentTool) -> None:
|
|
74
|
+
"""at.memory.store('just a string') must work."""
|
|
75
|
+
mock_resp = _mock_response(200, {
|
|
76
|
+
"id": "mem-1",
|
|
77
|
+
"content": "just a string",
|
|
78
|
+
"type": "semantic",
|
|
79
|
+
"importance": 0.5,
|
|
80
|
+
})
|
|
81
|
+
with patch.object(at._http, "post", return_value=mock_resp) as mock_post:
|
|
82
|
+
mem = at.memory.store("just a string")
|
|
83
|
+
|
|
84
|
+
assert isinstance(mem, Memory)
|
|
85
|
+
assert mem.id == "mem-1"
|
|
86
|
+
assert mem.content == "just a string"
|
|
87
|
+
# Verify the POST body
|
|
88
|
+
call_kwargs = mock_post.call_args
|
|
89
|
+
body = call_kwargs.kwargs.get("json") or call_kwargs[1].get("json")
|
|
90
|
+
assert body["content"] == "just a string"
|
|
91
|
+
assert body["type"] == "semantic"
|
|
92
|
+
|
|
93
|
+
def test_store_full(self, at: AgentTool) -> None:
|
|
94
|
+
mock_resp = _mock_response(200, {
|
|
95
|
+
"id": "mem-2",
|
|
96
|
+
"content": "hello",
|
|
97
|
+
"type": "episodic",
|
|
98
|
+
"agent_id": "agent-1",
|
|
99
|
+
"key": "greeting",
|
|
100
|
+
"metadata": {"source": "test"},
|
|
101
|
+
"importance": 0.9,
|
|
102
|
+
})
|
|
103
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
104
|
+
mem = at.memory.store(
|
|
105
|
+
"hello",
|
|
106
|
+
type="episodic",
|
|
107
|
+
agent_id="agent-1",
|
|
108
|
+
key="greeting",
|
|
109
|
+
metadata={"source": "test"},
|
|
110
|
+
importance=0.9,
|
|
111
|
+
)
|
|
112
|
+
assert mem.type == "episodic"
|
|
113
|
+
assert mem.agent_id == "agent-1"
|
|
114
|
+
assert mem.importance == 0.9
|
|
115
|
+
|
|
116
|
+
def test_search(self, at: AgentTool) -> None:
|
|
117
|
+
mock_resp = _mock_response(200, {
|
|
118
|
+
"results": [
|
|
119
|
+
{"id": "m1", "content": "hello world", "type": "semantic"},
|
|
120
|
+
{"id": "m2", "content": "goodbye", "type": "semantic"},
|
|
121
|
+
]
|
|
122
|
+
})
|
|
123
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
124
|
+
results = at.memory.search("hello")
|
|
125
|
+
assert len(results) == 2
|
|
126
|
+
assert all(isinstance(r, Memory) for r in results)
|
|
127
|
+
|
|
128
|
+
def test_search_list_response(self, at: AgentTool) -> None:
|
|
129
|
+
"""Handle APIs that return a raw list instead of {results: [...]}."""
|
|
130
|
+
mock_resp = _mock_response(200, [
|
|
131
|
+
{"id": "m1", "content": "item", "type": "semantic"},
|
|
132
|
+
])
|
|
133
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
134
|
+
results = at.memory.search("item")
|
|
135
|
+
assert len(results) == 1
|
|
136
|
+
|
|
137
|
+
def test_get(self, at: AgentTool) -> None:
|
|
138
|
+
mock_resp = _mock_response(200, {
|
|
139
|
+
"id": "mem-42",
|
|
140
|
+
"content": "remembered",
|
|
141
|
+
"type": "procedural",
|
|
142
|
+
})
|
|
143
|
+
with patch.object(at._http, "get", return_value=mock_resp):
|
|
144
|
+
mem = at.memory.get("mem-42")
|
|
145
|
+
assert mem.id == "mem-42"
|
|
146
|
+
assert mem.content == "remembered"
|
|
147
|
+
|
|
148
|
+
def test_usage(self, at: AgentTool) -> None:
|
|
149
|
+
mock_resp = _mock_response(200, {
|
|
150
|
+
"memories_stored": 100,
|
|
151
|
+
"searches_performed": 50,
|
|
152
|
+
"api_calls": 200,
|
|
153
|
+
})
|
|
154
|
+
with patch.object(at._http, "get", return_value=mock_resp):
|
|
155
|
+
usage = at.memory.usage()
|
|
156
|
+
assert usage.memories_stored == 100
|
|
157
|
+
assert usage.api_calls == 200
|
|
158
|
+
|
|
159
|
+
def test_error_raises(self, at: AgentTool) -> None:
|
|
160
|
+
mock_resp = _mock_response(401, {"detail": "Unauthorized"}, "Unauthorized")
|
|
161
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
162
|
+
with pytest.raises(AgentToolError) as exc_info:
|
|
163
|
+
at.memory.store("fail")
|
|
164
|
+
assert "401" in exc_info.value.message
|
|
165
|
+
assert exc_info.value.hint is not None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Tools
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
class TestTools:
|
|
173
|
+
@pytest.fixture()
|
|
174
|
+
def at(self) -> AgentTool:
|
|
175
|
+
with patch.dict(os.environ, {"AT_API_KEY": "test-key"}):
|
|
176
|
+
client = AgentTool()
|
|
177
|
+
return client
|
|
178
|
+
|
|
179
|
+
def test_search(self, at: AgentTool) -> None:
|
|
180
|
+
mock_resp = _mock_response(200, {
|
|
181
|
+
"results": [
|
|
182
|
+
{"title": "AI News", "url": "https://ai.com", "snippet": "Latest..."},
|
|
183
|
+
]
|
|
184
|
+
})
|
|
185
|
+
with patch.object(at._http, "post", return_value=mock_resp) as mock_post:
|
|
186
|
+
results = at.tools.search("AI news", num_results=3)
|
|
187
|
+
assert len(results) == 1
|
|
188
|
+
assert isinstance(results[0], SearchResult)
|
|
189
|
+
assert results[0].title == "AI News"
|
|
190
|
+
body = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
|
191
|
+
assert body["num_results"] == 3
|
|
192
|
+
|
|
193
|
+
def test_scrape(self, at: AgentTool) -> None:
|
|
194
|
+
mock_resp = _mock_response(200, {
|
|
195
|
+
"url": "https://example.com",
|
|
196
|
+
"content": "<h1>Hello</h1>",
|
|
197
|
+
"status_code": 200,
|
|
198
|
+
})
|
|
199
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
200
|
+
result = at.tools.scrape("https://example.com")
|
|
201
|
+
assert result.url == "https://example.com"
|
|
202
|
+
assert "<h1>" in result.content
|
|
203
|
+
|
|
204
|
+
def test_execute_python(self, at: AgentTool) -> None:
|
|
205
|
+
mock_resp = _mock_response(200, {
|
|
206
|
+
"output": "42\n",
|
|
207
|
+
"error": "",
|
|
208
|
+
"exit_code": 0,
|
|
209
|
+
})
|
|
210
|
+
with patch.object(at._http, "post", return_value=mock_resp) as mock_post:
|
|
211
|
+
result = at.tools.execute("print(42)")
|
|
212
|
+
assert result.output == "42\n"
|
|
213
|
+
assert result.exit_code == 0
|
|
214
|
+
body = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
|
215
|
+
assert body["language"] == "python"
|
|
216
|
+
|
|
217
|
+
def test_execute_javascript(self, at: AgentTool) -> None:
|
|
218
|
+
mock_resp = _mock_response(200, {
|
|
219
|
+
"output": "hello\n",
|
|
220
|
+
"error": "",
|
|
221
|
+
"exit_code": 0,
|
|
222
|
+
})
|
|
223
|
+
with patch.object(at._http, "post", return_value=mock_resp) as mock_post:
|
|
224
|
+
result = at.tools.execute("console.log('hello')", language="javascript")
|
|
225
|
+
assert result.output == "hello\n"
|
|
226
|
+
body = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
|
227
|
+
assert body["language"] == "javascript"
|
|
228
|
+
|
|
229
|
+
def test_error_raises(self, at: AgentTool) -> None:
|
|
230
|
+
mock_resp = _mock_response(500, {"detail": "Internal error"}, "Internal error")
|
|
231
|
+
with patch.object(at._http, "post", return_value=mock_resp):
|
|
232
|
+
with pytest.raises(AgentToolError) as exc_info:
|
|
233
|
+
at.tools.search("fail")
|
|
234
|
+
assert "500" in exc_info.value.message
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
238
|
+
# AgentToolError
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
class TestAgentToolError:
|
|
242
|
+
def test_message_and_hint(self) -> None:
|
|
243
|
+
err = AgentToolError("something broke", hint="try again")
|
|
244
|
+
assert err.message == "something broke"
|
|
245
|
+
assert err.hint == "try again"
|
|
246
|
+
assert "hint: try again" in str(err)
|
|
247
|
+
|
|
248
|
+
def test_no_hint(self) -> None:
|
|
249
|
+
err = AgentToolError("oops")
|
|
250
|
+
assert err.hint is None
|
|
251
|
+
assert str(err) == "oops"
|