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.
@@ -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"