strands-asyntai 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.
- strands_asyntai-0.1.0/.github/workflows/test.yml +24 -0
- strands_asyntai-0.1.0/.gitignore +7 -0
- strands_asyntai-0.1.0/LICENSE +21 -0
- strands_asyntai-0.1.0/PKG-INFO +113 -0
- strands_asyntai-0.1.0/README.md +86 -0
- strands_asyntai-0.1.0/pyproject.toml +43 -0
- strands_asyntai-0.1.0/src/strands_asyntai/__init__.py +13 -0
- strands_asyntai-0.1.0/src/strands_asyntai/client.py +146 -0
- strands_asyntai-0.1.0/src/strands_asyntai/plugin.py +163 -0
- strands_asyntai-0.1.0/tests/conftest.py +66 -0
- strands_asyntai-0.1.0/tests/test_client.py +77 -0
- strands_asyntai-0.1.0/tests/test_live.py +61 -0
- strands_asyntai-0.1.0/tests/test_plugin.py +231 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
16
|
+
strands: ["strands-agents==1.30.0", "strands-agents"]
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v4
|
|
19
|
+
- uses: actions/setup-python@v5
|
|
20
|
+
with:
|
|
21
|
+
python-version: ${{ matrix.python-version }}
|
|
22
|
+
- run: python -m pip install --upgrade pip
|
|
23
|
+
- run: pip install -e ".[test]" "${{ matrix.strands }}"
|
|
24
|
+
- run: pytest tests/test_client.py tests/test_plugin.py -v
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Asyntai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: strands-asyntai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Asyntai tools for Strands Agents: give your agent a whole website's knowledge, chats and leads
|
|
5
|
+
Project-URL: Homepage, https://asyntai.com/documentation/integrations/strands/
|
|
6
|
+
Project-URL: Documentation, https://asyntai.com/documentation/integrations/strands/
|
|
7
|
+
Project-URL: Source, https://github.com/asyntai/strands-asyntai
|
|
8
|
+
Author-email: Asyntai <hello@asyntai.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,asyntai,chatbot,leads,plugin,rag,strands,strands-agents,tools,website
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: strands-agents>=1.30.0
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# strands-asyntai
|
|
29
|
+
|
|
30
|
+
[Strands Agents](https://strandsagents.com) tools for [Asyntai](https://asyntai.com), the AI chat agent for websites.
|
|
31
|
+
|
|
32
|
+
Point Asyntai at a website. It reads the pages, the product feed and the documents, and keeps them current. `AsyntaiPlugin` hands that knowledge to any Strands agent, together with the website's chats and leads. Your agent answers questions about a real business without you building or maintaining an index.
|
|
33
|
+
|
|
34
|
+
Full guide: [asyntai.com/documentation/integrations/strands](https://asyntai.com/documentation/integrations/strands/)
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install strands-asyntai
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Set the key
|
|
43
|
+
|
|
44
|
+
Create an API key in the Asyntai dashboard under **Settings → API**. The API is available on the Starter plan and higher.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
export ASYNTAI_API_KEY="your-key"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Use it
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from strands import Agent
|
|
54
|
+
from strands_asyntai import AsyntaiPlugin
|
|
55
|
+
|
|
56
|
+
agent = Agent(plugins=[AsyntaiPlugin()])
|
|
57
|
+
|
|
58
|
+
agent('Do they ship to Spain, and how long does it take?')
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The questions of one plugin instance share one website conversation, so follow-up questions keep their context. To continue a conversation later, for example one per customer, pass `session_id="customer-42"`. Every conversation also shows in your Asyntai chat logs.
|
|
62
|
+
|
|
63
|
+
## The tools
|
|
64
|
+
|
|
65
|
+
| Tool | What the agent can do | On by default |
|
|
66
|
+
| --- | --- | --- |
|
|
67
|
+
| `asyntai_ask_website` | Answer a question from the website: products, prices, stock, delivery, opening hours, policies, documentation. | Yes |
|
|
68
|
+
| `asyntai_list_websites` | List the website agents on the account, with their ids. | Yes |
|
|
69
|
+
| `asyntai_list_knowledge` | List the pages, texts and files the website agent knows. | Yes |
|
|
70
|
+
| `asyntai_get_leads` | Read the visitors who left an email address or a phone number. | Yes (`lead_tools`) |
|
|
71
|
+
| `asyntai_get_conversation` | Read the messages of one website chat. | Yes (`lead_tools`) |
|
|
72
|
+
| `asyntai_add_text` | Store a text so the website agent uses it in later answers. | No (`knowledge_tools`) |
|
|
73
|
+
| `asyntai_add_url` | Read a page into the knowledge base. | No (`knowledge_tools`) |
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
AsyntaiPlugin(
|
|
77
|
+
website_id=42, # which website agent, when the account has several
|
|
78
|
+
lead_tools=False, # hide visitor contact details and chats
|
|
79
|
+
knowledge_tools=True, # allow writing to the knowledge base
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Use the tools without the plugin
|
|
84
|
+
|
|
85
|
+
`asyntai_tools()` takes the same arguments and returns a plain list, so you can mix the tools with your own:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from strands import Agent
|
|
89
|
+
from strands_asyntai import asyntai_tools
|
|
90
|
+
|
|
91
|
+
agent = Agent(tools=[*asyntai_tools(lead_tools=False), my_other_tool])
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Errors
|
|
95
|
+
|
|
96
|
+
A failed call reaches the model as a tool error with a clear message, for example a wrong API key or a wrong `website_id`. The model can then correct the call or explain the problem to the user.
|
|
97
|
+
|
|
98
|
+
## Requirements
|
|
99
|
+
|
|
100
|
+
Python 3.10 or higher and `strands-agents` 1.30 or higher.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install -e ".[test]"
|
|
106
|
+
pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`tests/test_live.py` runs against a real Asyntai server when `ASYNTAI_LIVE_KEY` and `ASYNTAI_LIVE_URL` are set.
|
|
110
|
+
|
|
111
|
+
## License
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# strands-asyntai
|
|
2
|
+
|
|
3
|
+
[Strands Agents](https://strandsagents.com) tools for [Asyntai](https://asyntai.com), the AI chat agent for websites.
|
|
4
|
+
|
|
5
|
+
Point Asyntai at a website. It reads the pages, the product feed and the documents, and keeps them current. `AsyntaiPlugin` hands that knowledge to any Strands agent, together with the website's chats and leads. Your agent answers questions about a real business without you building or maintaining an index.
|
|
6
|
+
|
|
7
|
+
Full guide: [asyntai.com/documentation/integrations/strands](https://asyntai.com/documentation/integrations/strands/)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install strands-asyntai
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Set the key
|
|
16
|
+
|
|
17
|
+
Create an API key in the Asyntai dashboard under **Settings → API**. The API is available on the Starter plan and higher.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export ASYNTAI_API_KEY="your-key"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Use it
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from strands import Agent
|
|
27
|
+
from strands_asyntai import AsyntaiPlugin
|
|
28
|
+
|
|
29
|
+
agent = Agent(plugins=[AsyntaiPlugin()])
|
|
30
|
+
|
|
31
|
+
agent('Do they ship to Spain, and how long does it take?')
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The questions of one plugin instance share one website conversation, so follow-up questions keep their context. To continue a conversation later, for example one per customer, pass `session_id="customer-42"`. Every conversation also shows in your Asyntai chat logs.
|
|
35
|
+
|
|
36
|
+
## The tools
|
|
37
|
+
|
|
38
|
+
| Tool | What the agent can do | On by default |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `asyntai_ask_website` | Answer a question from the website: products, prices, stock, delivery, opening hours, policies, documentation. | Yes |
|
|
41
|
+
| `asyntai_list_websites` | List the website agents on the account, with their ids. | Yes |
|
|
42
|
+
| `asyntai_list_knowledge` | List the pages, texts and files the website agent knows. | Yes |
|
|
43
|
+
| `asyntai_get_leads` | Read the visitors who left an email address or a phone number. | Yes (`lead_tools`) |
|
|
44
|
+
| `asyntai_get_conversation` | Read the messages of one website chat. | Yes (`lead_tools`) |
|
|
45
|
+
| `asyntai_add_text` | Store a text so the website agent uses it in later answers. | No (`knowledge_tools`) |
|
|
46
|
+
| `asyntai_add_url` | Read a page into the knowledge base. | No (`knowledge_tools`) |
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
AsyntaiPlugin(
|
|
50
|
+
website_id=42, # which website agent, when the account has several
|
|
51
|
+
lead_tools=False, # hide visitor contact details and chats
|
|
52
|
+
knowledge_tools=True, # allow writing to the knowledge base
|
|
53
|
+
)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Use the tools without the plugin
|
|
57
|
+
|
|
58
|
+
`asyntai_tools()` takes the same arguments and returns a plain list, so you can mix the tools with your own:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from strands import Agent
|
|
62
|
+
from strands_asyntai import asyntai_tools
|
|
63
|
+
|
|
64
|
+
agent = Agent(tools=[*asyntai_tools(lead_tools=False), my_other_tool])
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Errors
|
|
68
|
+
|
|
69
|
+
A failed call reaches the model as a tool error with a clear message, for example a wrong API key or a wrong `website_id`. The model can then correct the call or explain the problem to the user.
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
Python 3.10 or higher and `strands-agents` 1.30 or higher.
|
|
74
|
+
|
|
75
|
+
## Tests
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
pip install -e ".[test]"
|
|
79
|
+
pytest
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`tests/test_live.py` runs against a real Asyntai server when `ASYNTAI_LIVE_KEY` and `ASYNTAI_LIVE_URL` are set.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "strands-asyntai"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Asyntai tools for Strands Agents: give your agent a whole website's knowledge, chats and leads"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Asyntai", email = "hello@asyntai.com" }]
|
|
13
|
+
keywords = ["strands", "strands-agents", "agents", "tools", "plugin", "asyntai", "rag", "chatbot", "website", "leads"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"strands-agents>=1.30.0",
|
|
27
|
+
"httpx>=0.27.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://asyntai.com/documentation/integrations/strands/"
|
|
32
|
+
Documentation = "https://asyntai.com/documentation/integrations/strands/"
|
|
33
|
+
Source = "https://github.com/asyntai/strands-asyntai"
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = ["pytest>=8.0"]
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["src/strands_asyntai"]
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
testpaths = ["tests"]
|
|
43
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Asyntai tools for Strands Agents."""
|
|
2
|
+
|
|
3
|
+
from .client import AsyntaiClient, AsyntaiError, AsyntaiRequestError
|
|
4
|
+
from .plugin import AsyntaiPlugin, asyntai_tools
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'AsyntaiClient',
|
|
8
|
+
'AsyntaiError',
|
|
9
|
+
'AsyntaiPlugin',
|
|
10
|
+
'AsyntaiRequestError',
|
|
11
|
+
'asyntai_tools',
|
|
12
|
+
]
|
|
13
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""HTTP client for the Asyntai public API.
|
|
2
|
+
|
|
3
|
+
Every tool in this package goes through this client, so authentication,
|
|
4
|
+
timeouts and error messages have one implementation. The API is documented at
|
|
5
|
+
https://asyntai.com/documentation/api-reference/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = 'https://asyntai.com'
|
|
16
|
+
DEFAULT_TIMEOUT = 60.0
|
|
17
|
+
USER_AGENT = 'strands-asyntai'
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyntaiError(RuntimeError):
|
|
21
|
+
"""The Asyntai API refused a call, or could not be reached."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AsyntaiRequestError(AsyntaiError):
|
|
25
|
+
"""The Asyntai API refused the arguments of one call (HTTP 400 or 404)."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def resolve_api_key(api_key: str | None) -> str:
|
|
29
|
+
key = api_key or os.environ.get('ASYNTAI_API_KEY', '')
|
|
30
|
+
if not key:
|
|
31
|
+
raise AsyntaiError(
|
|
32
|
+
'An Asyntai API key is required. Pass api_key=... or set the '
|
|
33
|
+
'ASYNTAI_API_KEY environment variable. Create a key in the Asyntai '
|
|
34
|
+
'dashboard under Settings -> API.'
|
|
35
|
+
)
|
|
36
|
+
return key
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AsyntaiClient:
|
|
40
|
+
"""Calls the Asyntai REST API with one API key."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
api_key: str | None = None,
|
|
45
|
+
*,
|
|
46
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
47
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
48
|
+
transport: httpx.BaseTransport | None = None,
|
|
49
|
+
) -> None:
|
|
50
|
+
self.api_key = resolve_api_key(api_key)
|
|
51
|
+
self.base_url = base_url.rstrip('/')
|
|
52
|
+
self.timeout = timeout
|
|
53
|
+
self._transport = transport
|
|
54
|
+
|
|
55
|
+
def _request(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
params: dict[str, Any] | None = None,
|
|
61
|
+
json_body: dict[str, Any] | None = None,
|
|
62
|
+
) -> dict[str, Any]:
|
|
63
|
+
headers = {
|
|
64
|
+
'Authorization': f'Bearer {self.api_key}',
|
|
65
|
+
'Accept': 'application/json',
|
|
66
|
+
'User-Agent': USER_AGENT,
|
|
67
|
+
}
|
|
68
|
+
try:
|
|
69
|
+
with httpx.Client(base_url=self.base_url, timeout=self.timeout, transport=self._transport) as http:
|
|
70
|
+
response = http.request(method, path, headers=headers, params=params, json=json_body)
|
|
71
|
+
except httpx.HTTPError as exc:
|
|
72
|
+
raise AsyntaiError(f'Could not reach the Asyntai API: {exc}') from exc
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
payload = response.json()
|
|
76
|
+
except ValueError:
|
|
77
|
+
raise AsyntaiError(f'Asyntai returned a non-JSON response (HTTP {response.status_code}).') from None
|
|
78
|
+
if not isinstance(payload, dict):
|
|
79
|
+
raise AsyntaiError('Asyntai returned an unexpected response body.')
|
|
80
|
+
|
|
81
|
+
if response.status_code == 401:
|
|
82
|
+
raise AsyntaiError('Asyntai rejected the API key. Check the key in Settings -> API.')
|
|
83
|
+
if response.status_code == 403:
|
|
84
|
+
raise AsyntaiError('The Asyntai API needs the Starter plan or higher. See https://asyntai.com/pricing/.')
|
|
85
|
+
if response.status_code == 429:
|
|
86
|
+
raise AsyntaiError('The Asyntai message limit for this month is reached.')
|
|
87
|
+
message = payload.get('error') or f'Asyntai API error (HTTP {response.status_code}).'
|
|
88
|
+
if response.status_code in (400, 404):
|
|
89
|
+
raise AsyntaiRequestError(message)
|
|
90
|
+
if not payload.get('success', False):
|
|
91
|
+
raise AsyntaiError(message)
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
def chat(self, message: str, *, session_id: str | None = None, website_id: int | None = None) -> dict[str, Any]:
|
|
95
|
+
"""POST /api/v1/chat/: ask the website agent a question."""
|
|
96
|
+
body: dict[str, Any] = {'message': message}
|
|
97
|
+
if session_id:
|
|
98
|
+
body['session_id'] = session_id
|
|
99
|
+
if website_id is not None:
|
|
100
|
+
body['website_id'] = website_id
|
|
101
|
+
return self._request('POST', '/api/v1/chat/', json_body=body)
|
|
102
|
+
|
|
103
|
+
def websites(self) -> list[dict[str, Any]]:
|
|
104
|
+
"""GET /api/v1/websites/: the website agents on this account."""
|
|
105
|
+
return self._request('GET', '/api/v1/websites/').get('websites', [])
|
|
106
|
+
|
|
107
|
+
def knowledge(self, *, limit: int = 50, website_id: int | None = None) -> list[dict[str, Any]]:
|
|
108
|
+
"""GET /api/v1/knowledge/: what the website agent knows."""
|
|
109
|
+
params: dict[str, Any] = {'limit': limit}
|
|
110
|
+
if website_id is not None:
|
|
111
|
+
params['website_id'] = website_id
|
|
112
|
+
return self._request('GET', '/api/v1/knowledge/', params=params).get('entries', [])
|
|
113
|
+
|
|
114
|
+
def add_text(self, title: str, content: str, *, website_id: int | None = None) -> dict[str, Any]:
|
|
115
|
+
"""POST /api/v1/knowledge/text/: store a text in the knowledge base."""
|
|
116
|
+
body: dict[str, Any] = {'title': title, 'content': content}
|
|
117
|
+
if website_id is not None:
|
|
118
|
+
body['website_id'] = website_id
|
|
119
|
+
return self._request('POST', '/api/v1/knowledge/text/', json_body=body)
|
|
120
|
+
|
|
121
|
+
def add_url(self, url: str, *, website_id: int | None = None) -> dict[str, Any]:
|
|
122
|
+
"""POST /api/v1/knowledge/url/: read a page into the knowledge base."""
|
|
123
|
+
body: dict[str, Any] = {'url': url}
|
|
124
|
+
if website_id is not None:
|
|
125
|
+
body['website_id'] = website_id
|
|
126
|
+
return self._request('POST', '/api/v1/knowledge/url/', json_body=body)
|
|
127
|
+
|
|
128
|
+
def leads(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
limit: int = 20,
|
|
132
|
+
website_id: int | None = None,
|
|
133
|
+
since: str | None = None,
|
|
134
|
+
) -> list[dict[str, Any]]:
|
|
135
|
+
"""GET /api/v1/leads/: visitors who left an email address or a phone number."""
|
|
136
|
+
params: dict[str, Any] = {'limit': limit}
|
|
137
|
+
if website_id is not None:
|
|
138
|
+
params['website_id'] = website_id
|
|
139
|
+
if since:
|
|
140
|
+
params['since'] = since
|
|
141
|
+
return self._request('GET', '/api/v1/leads/', params=params).get('leads', [])
|
|
142
|
+
|
|
143
|
+
def conversation(self, session_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
|
|
144
|
+
"""GET /api/v1/conversations/: the messages of one website chat."""
|
|
145
|
+
params = {'session_id': session_id, 'limit': limit}
|
|
146
|
+
return self._request('GET', '/api/v1/conversations/', params=params).get('messages', [])
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The Asyntai plugin for Strands Agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from strands import tool
|
|
10
|
+
from strands.plugins import Plugin
|
|
11
|
+
from strands.tools.decorator import DecoratedFunctionTool
|
|
12
|
+
|
|
13
|
+
from .client import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, AsyntaiClient
|
|
14
|
+
|
|
15
|
+
LEAD_TOOLS = frozenset({'asyntai_get_leads', 'asyntai_get_conversation'})
|
|
16
|
+
KNOWLEDGE_TOOLS = frozenset({'asyntai_add_text', 'asyntai_add_url'})
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AsyntaiPlugin(Plugin):
|
|
20
|
+
"""Gives an agent the knowledge, chats and leads of an Asyntai website agent.
|
|
21
|
+
|
|
22
|
+
Asyntai reads a website, its product feed and its documents, and keeps that
|
|
23
|
+
knowledge current. This plugin hands it to a Strands agent as tools, so the
|
|
24
|
+
agent answers questions about a real business without a local index.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
```python
|
|
28
|
+
from strands import Agent
|
|
29
|
+
from strands_asyntai import AsyntaiPlugin
|
|
30
|
+
|
|
31
|
+
agent = Agent(plugins=[AsyntaiPlugin()])
|
|
32
|
+
agent('Do they ship to Spain, and how long does it take?')
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
api_key: Asyntai API key. Defaults to the ``ASYNTAI_API_KEY`` environment variable.
|
|
37
|
+
website_id: Which website agent to use, when the account has several.
|
|
38
|
+
``asyntai_list_websites`` returns the ids.
|
|
39
|
+
knowledge_tools: Also register ``asyntai_add_text`` and ``asyntai_add_url``,
|
|
40
|
+
which write to the knowledge base. Off by default.
|
|
41
|
+
lead_tools: Register ``asyntai_get_leads`` and ``asyntai_get_conversation``,
|
|
42
|
+
which read visitor contact details and chats. On by default.
|
|
43
|
+
session_id: The website conversation to continue, for example one per customer.
|
|
44
|
+
By default each plugin instance starts its own conversation.
|
|
45
|
+
base_url: API address. Change it only for a self-hosted test server.
|
|
46
|
+
timeout: Seconds to wait for one API call.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name = 'asyntai'
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
api_key: str | None = None,
|
|
54
|
+
*,
|
|
55
|
+
website_id: int | None = None,
|
|
56
|
+
knowledge_tools: bool = False,
|
|
57
|
+
lead_tools: bool = True,
|
|
58
|
+
session_id: str | None = None,
|
|
59
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
60
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
61
|
+
transport: httpx.BaseTransport | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.client = AsyntaiClient(api_key, base_url=base_url, timeout=timeout, transport=transport)
|
|
64
|
+
self.website_id = website_id
|
|
65
|
+
# Without a session id the Asyntai API puts every call of the account into
|
|
66
|
+
# one shared conversation, so each plugin instance gets its own.
|
|
67
|
+
self.session_id = session_id or f'strands-{uuid.uuid4().hex}'
|
|
68
|
+
super().__init__()
|
|
69
|
+
hidden: set[str] = set()
|
|
70
|
+
if not lead_tools:
|
|
71
|
+
hidden |= LEAD_TOOLS
|
|
72
|
+
if not knowledge_tools:
|
|
73
|
+
hidden |= KNOWLEDGE_TOOLS
|
|
74
|
+
self._tools = [t for t in self._tools if t.tool_name not in hidden]
|
|
75
|
+
|
|
76
|
+
@tool
|
|
77
|
+
def asyntai_ask_website(self, question: str) -> str:
|
|
78
|
+
"""Ask the business's Asyntai website agent a question and get its answer.
|
|
79
|
+
|
|
80
|
+
Use it instead of guessing for anything about the business: products, prices,
|
|
81
|
+
stock, delivery, returns, opening hours, policies and documentation. Asyntai
|
|
82
|
+
answers from the business's own website, product catalog and documents. Ask
|
|
83
|
+
follow-up questions freely: the website agent keeps the context of the conversation.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
question: The question, in any language.
|
|
87
|
+
"""
|
|
88
|
+
payload = self.client.chat(question, session_id=self.session_id, website_id=self.website_id)
|
|
89
|
+
return str(payload.get('response', ''))
|
|
90
|
+
|
|
91
|
+
@tool
|
|
92
|
+
def asyntai_list_websites(self) -> list[dict[str, Any]] | str:
|
|
93
|
+
"""List the website agents on this Asyntai account, with their ids and domains."""
|
|
94
|
+
return self.client.websites() or 'This Asyntai account has no website agents yet.'
|
|
95
|
+
|
|
96
|
+
@tool
|
|
97
|
+
def asyntai_list_knowledge(self, limit: int = 50) -> list[dict[str, Any]] | str:
|
|
98
|
+
"""List the pages, texts and files the website agent knows.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
limit: How many entries to return, 1 to 200.
|
|
102
|
+
"""
|
|
103
|
+
entries = self.client.knowledge(limit=_clamp(limit, 1, 200), website_id=self.website_id)
|
|
104
|
+
return entries or 'The knowledge base of this website agent is empty.'
|
|
105
|
+
|
|
106
|
+
@tool
|
|
107
|
+
def asyntai_get_leads(self, limit: int = 20, since: str | None = None) -> list[dict[str, Any]] | str:
|
|
108
|
+
"""Read the website visitors who left an email address or a phone number.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
limit: How many leads to return, newest first, 1 to 100.
|
|
112
|
+
since: Only leads after this ISO date, for example 2026-10-01.
|
|
113
|
+
"""
|
|
114
|
+
leads = self.client.leads(limit=_clamp(limit, 1, 100), website_id=self.website_id, since=since)
|
|
115
|
+
if leads:
|
|
116
|
+
return leads
|
|
117
|
+
period = f' since {since}' if since else ''
|
|
118
|
+
return f'No website visitor left an email address or a phone number{period}. The call worked.'
|
|
119
|
+
|
|
120
|
+
@tool
|
|
121
|
+
def asyntai_get_conversation(self, session_id: str, limit: int = 50) -> list[dict[str, Any]] | str:
|
|
122
|
+
"""Read the messages of one website chat.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
session_id: The chat's session id, as returned with a lead.
|
|
126
|
+
limit: How many messages to return, 1 to 200.
|
|
127
|
+
"""
|
|
128
|
+
messages = self.client.conversation(session_id, limit=_clamp(limit, 1, 200))
|
|
129
|
+
return messages or f'The chat {session_id} has no messages.'
|
|
130
|
+
|
|
131
|
+
@tool
|
|
132
|
+
def asyntai_add_text(self, title: str, content: str) -> str:
|
|
133
|
+
"""Store a text in the knowledge base, so the website agent uses it in later answers.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
title: A short title for the text.
|
|
137
|
+
content: The text itself.
|
|
138
|
+
"""
|
|
139
|
+
self.client.add_text(title, content, website_id=self.website_id)
|
|
140
|
+
return f'Stored "{title}" in the knowledge base.'
|
|
141
|
+
|
|
142
|
+
@tool
|
|
143
|
+
def asyntai_add_url(self, url: str) -> str:
|
|
144
|
+
"""Read a web page into the knowledge base.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
url: The full address of the page, starting with https://.
|
|
148
|
+
"""
|
|
149
|
+
self.client.add_url(url, website_id=self.website_id)
|
|
150
|
+
return f'Asyntai is reading {url} into the knowledge base.'
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def asyntai_tools(api_key: str | None = None, **kwargs: Any) -> list[DecoratedFunctionTool]:
|
|
154
|
+
"""Return the Asyntai tools as a plain list, for ``Agent(tools=...)``.
|
|
155
|
+
|
|
156
|
+
Takes the same arguments as :class:`AsyntaiPlugin`. Use it to mix the tools
|
|
157
|
+
with your own, or to give them to an agent that does not use plugins.
|
|
158
|
+
"""
|
|
159
|
+
return AsyntaiPlugin(api_key, **kwargs).tools
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _clamp(value: int, low: int, high: int) -> int:
|
|
163
|
+
return max(low, min(high, int(value)))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FakeAsyntai:
|
|
13
|
+
"""A fake Asyntai API that records every request."""
|
|
14
|
+
|
|
15
|
+
requests: list[httpx.Request] = field(default_factory=list)
|
|
16
|
+
status: dict[str, int] = field(default_factory=dict)
|
|
17
|
+
empty: bool = False
|
|
18
|
+
|
|
19
|
+
def handler(self, request: httpx.Request) -> httpx.Response:
|
|
20
|
+
self.requests.append(request)
|
|
21
|
+
path = request.url.path
|
|
22
|
+
if path in self.status:
|
|
23
|
+
code = self.status[path]
|
|
24
|
+
return httpx.Response(code, json={'success': False, 'error': f'fake error {code}'})
|
|
25
|
+
if request.headers.get('authorization') != 'Bearer test-key':
|
|
26
|
+
return httpx.Response(401, json={'success': False, 'error': 'Invalid API key'})
|
|
27
|
+
if path == '/api/v1/chat/':
|
|
28
|
+
payload = json.loads(request.content)
|
|
29
|
+
return httpx.Response(
|
|
30
|
+
200,
|
|
31
|
+
json={'success': True, 'response': f'Answer to: {payload["message"]}', 'session_id': 'x'},
|
|
32
|
+
)
|
|
33
|
+
if path == '/api/v1/websites/':
|
|
34
|
+
return httpx.Response(200, json={'success': True, 'websites': [] if self.empty else [{'id': 7}]})
|
|
35
|
+
if path == '/api/v1/knowledge/':
|
|
36
|
+
return httpx.Response(200, json={'success': True, 'entries': [] if self.empty else [{'id': 'k1'}]})
|
|
37
|
+
if path in ('/api/v1/knowledge/text/', '/api/v1/knowledge/url/'):
|
|
38
|
+
return httpx.Response(201, json={'success': True, 'id': 'k2'})
|
|
39
|
+
if path == '/api/v1/leads/':
|
|
40
|
+
leads = [] if self.empty else [{'session_id': 's1', 'email': 'ana@example.test'}]
|
|
41
|
+
return httpx.Response(200, json={'success': True, 'leads': leads})
|
|
42
|
+
if path == '/api/v1/conversations/':
|
|
43
|
+
messages = [] if self.empty else [{'role': 'user', 'content': 'Do you ship to Spain?'}]
|
|
44
|
+
return httpx.Response(200, json={'success': True, 'messages': messages})
|
|
45
|
+
return httpx.Response(404, json={'success': False, 'error': 'Not found'})
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def transport(self) -> httpx.MockTransport:
|
|
49
|
+
return httpx.MockTransport(self.handler)
|
|
50
|
+
|
|
51
|
+
def last(self, path: str) -> httpx.Request:
|
|
52
|
+
return [r for r in self.requests if r.url.path == path][-1]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture
|
|
56
|
+
def fake() -> FakeAsyntai:
|
|
57
|
+
return FakeAsyntai()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture(autouse=True)
|
|
61
|
+
def _no_env_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
62
|
+
monkeypatch.delenv('ASYNTAI_API_KEY', raising=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def body(request: httpx.Request) -> dict[str, Any]:
|
|
66
|
+
return json.loads(request.content)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
import pytest
|
|
5
|
+
from conftest import FakeAsyntai, body
|
|
6
|
+
|
|
7
|
+
from strands_asyntai import AsyntaiClient, AsyntaiError, AsyntaiRequestError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def client(fake: FakeAsyntai, key: str = 'test-key') -> AsyntaiClient:
|
|
11
|
+
return AsyntaiClient(key, base_url='https://api.test', transport=fake.transport)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_key_is_required() -> None:
|
|
15
|
+
with pytest.raises(AsyntaiError, match='API key is required'):
|
|
16
|
+
AsyntaiClient()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_key_comes_from_the_environment(monkeypatch: pytest.MonkeyPatch, fake: FakeAsyntai) -> None:
|
|
20
|
+
monkeypatch.setenv('ASYNTAI_API_KEY', 'test-key')
|
|
21
|
+
assert AsyntaiClient(base_url='https://api.test', transport=fake.transport).websites() == [{'id': 7}]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_sends_bearer_key_and_user_agent(fake: FakeAsyntai) -> None:
|
|
25
|
+
client(fake).websites()
|
|
26
|
+
request = fake.requests[-1]
|
|
27
|
+
assert request.headers['authorization'] == 'Bearer test-key'
|
|
28
|
+
assert request.headers['user-agent'] == 'strands-asyntai'
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_chat_body_has_only_what_was_given(fake: FakeAsyntai) -> None:
|
|
32
|
+
client(fake).chat('Hi')
|
|
33
|
+
assert body(fake.last('/api/v1/chat/')) == {'message': 'Hi'}
|
|
34
|
+
client(fake).chat('Hi', session_id='s', website_id=3)
|
|
35
|
+
assert body(fake.last('/api/v1/chat/')) == {'message': 'Hi', 'session_id': 's', 'website_id': 3}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_leads_params(fake: FakeAsyntai) -> None:
|
|
39
|
+
client(fake).leads(limit=5, website_id=3, since='2026-10-01')
|
|
40
|
+
params = fake.last('/api/v1/leads/').url.params
|
|
41
|
+
assert (params['limit'], params['website_id'], params['since']) == ('5', '3', '2026-10-01')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize(
|
|
45
|
+
('code', 'expected', 'kind'),
|
|
46
|
+
[
|
|
47
|
+
(401, 'rejected the API key', AsyntaiError),
|
|
48
|
+
(403, 'Starter plan', AsyntaiError),
|
|
49
|
+
(429, 'message limit', AsyntaiError),
|
|
50
|
+
(400, 'fake error 400', AsyntaiRequestError),
|
|
51
|
+
(404, 'fake error 404', AsyntaiRequestError),
|
|
52
|
+
(500, 'fake error 500', AsyntaiError),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
def test_error_codes(fake: FakeAsyntai, code: int, expected: str, kind: type[Exception]) -> None:
|
|
56
|
+
fake.status['/api/v1/websites/'] = code
|
|
57
|
+
with pytest.raises(kind, match=expected):
|
|
58
|
+
client(fake).websites()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_network_error() -> None:
|
|
62
|
+
def fail(request: httpx.Request) -> httpx.Response:
|
|
63
|
+
raise httpx.ConnectError('boom')
|
|
64
|
+
|
|
65
|
+
with pytest.raises(AsyntaiError, match='Could not reach'):
|
|
66
|
+
AsyntaiClient('k', base_url='https://api.test', transport=httpx.MockTransport(fail)).websites()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_html_response() -> None:
|
|
70
|
+
transport = httpx.MockTransport(lambda r: httpx.Response(502, text='<html>'))
|
|
71
|
+
with pytest.raises(AsyntaiError, match='non-JSON'):
|
|
72
|
+
AsyntaiClient('k', base_url='https://api.test', transport=transport).websites()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_base_url_trailing_slash(fake: FakeAsyntai) -> None:
|
|
76
|
+
AsyntaiClient('test-key', base_url='https://api.test/', transport=fake.transport).websites()
|
|
77
|
+
assert str(fake.requests[-1].url) == 'https://api.test/api/v1/websites/'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Runs against a real Asyntai server. Skipped unless both variables are set.
|
|
2
|
+
|
|
3
|
+
ASYNTAI_LIVE_KEY=... ASYNTAI_LIVE_URL=https://asyntai.com pytest tests/test_live.py
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from strands import Agent
|
|
13
|
+
from strands.models import Model
|
|
14
|
+
|
|
15
|
+
from strands_asyntai import AsyntaiClient, AsyntaiError, AsyntaiPlugin
|
|
16
|
+
|
|
17
|
+
KEY = os.environ.get('ASYNTAI_LIVE_KEY')
|
|
18
|
+
URL = os.environ.get('ASYNTAI_LIVE_URL')
|
|
19
|
+
WEBSITE = os.environ.get('ASYNTAI_LIVE_WEBSITE_ID')
|
|
20
|
+
|
|
21
|
+
pytestmark = pytest.mark.skipif(not (KEY and URL), reason='set ASYNTAI_LIVE_KEY and ASYNTAI_LIVE_URL')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NoModel(Model):
|
|
25
|
+
"""The tools are called directly, so the model is never used."""
|
|
26
|
+
|
|
27
|
+
def update_config(self, **model_config: Any) -> None: ...
|
|
28
|
+
|
|
29
|
+
def get_config(self) -> Any:
|
|
30
|
+
return {}
|
|
31
|
+
|
|
32
|
+
def structured_output(self, *args: Any, **kwargs: Any) -> Any:
|
|
33
|
+
raise NotImplementedError
|
|
34
|
+
|
|
35
|
+
def stream(self, *args: Any, **kwargs: Any) -> Any:
|
|
36
|
+
raise NotImplementedError
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def agent() -> Agent:
|
|
40
|
+
plugin = AsyntaiPlugin(KEY, base_url=URL or '', website_id=int(WEBSITE) if WEBSITE else None, timeout=120)
|
|
41
|
+
return Agent(model=NoModel(), plugins=[plugin], callback_handler=None)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_wrong_key_is_rejected() -> None:
|
|
45
|
+
with pytest.raises(AsyntaiError, match='rejected the API key'):
|
|
46
|
+
AsyntaiClient('wrong-key', base_url=URL or '').websites()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize('name', ['asyntai_list_websites', 'asyntai_list_knowledge', 'asyntai_get_leads'])
|
|
50
|
+
def test_read_tools(name: str) -> None:
|
|
51
|
+
live_agent = agent()
|
|
52
|
+
result = getattr(live_agent.tool, name)()
|
|
53
|
+
assert result['status'] == 'success'
|
|
54
|
+
assert result['content'][0]['text']
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_ask_website_answers() -> None:
|
|
58
|
+
live_agent = agent()
|
|
59
|
+
result = live_agent.tool.asyntai_ask_website(question='What do you sell?')
|
|
60
|
+
assert result['status'] == 'success'
|
|
61
|
+
assert result['content'][0]['text'].strip()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from conftest import FakeAsyntai, body
|
|
8
|
+
from strands import Agent
|
|
9
|
+
from strands.models import Model
|
|
10
|
+
|
|
11
|
+
from strands_asyntai import AsyntaiPlugin, asyntai_tools
|
|
12
|
+
|
|
13
|
+
READ = {'asyntai_ask_website', 'asyntai_list_websites', 'asyntai_list_knowledge'}
|
|
14
|
+
LEADS = {'asyntai_get_leads', 'asyntai_get_conversation'}
|
|
15
|
+
WRITE = {'asyntai_add_text', 'asyntai_add_url'}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScriptedModel(Model):
|
|
19
|
+
"""Calls the given tools one by one, then finishes. Records what it was offered."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, calls: list[tuple[str, dict[str, Any]]]) -> None:
|
|
22
|
+
self.calls = list(calls)
|
|
23
|
+
self.offered: list[str] = []
|
|
24
|
+
self.results: list[dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
def update_config(self, **model_config: Any) -> None: ...
|
|
27
|
+
|
|
28
|
+
def get_config(self) -> Any:
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
async def structured_output(self, *args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]:
|
|
32
|
+
raise NotImplementedError
|
|
33
|
+
yield
|
|
34
|
+
|
|
35
|
+
async def stream(self, messages: Any, tool_specs: Any = None, *args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]:
|
|
36
|
+
self.offered = [spec['name'] for spec in tool_specs or []]
|
|
37
|
+
self.results = [
|
|
38
|
+
block['toolResult'] for message in messages for block in message['content'] if 'toolResult' in block
|
|
39
|
+
]
|
|
40
|
+
yield {'messageStart': {'role': 'assistant'}}
|
|
41
|
+
if self.calls:
|
|
42
|
+
name, arguments = self.calls.pop(0)
|
|
43
|
+
yield {'contentBlockStart': {'start': {'toolUse': {'toolUseId': f'id-{len(self.calls)}', 'name': name}}}}
|
|
44
|
+
yield {'contentBlockDelta': {'delta': {'toolUse': {'input': json.dumps(arguments)}}}}
|
|
45
|
+
yield {'contentBlockStop': {}}
|
|
46
|
+
yield {'messageStop': {'stopReason': 'tool_use'}}
|
|
47
|
+
else:
|
|
48
|
+
yield {'contentBlockDelta': {'delta': {'text': 'Done.'}}}
|
|
49
|
+
yield {'contentBlockStop': {}}
|
|
50
|
+
yield {'messageStop': {'stopReason': 'end_turn'}}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def plugin(fake: FakeAsyntai, **kwargs: Any) -> AsyntaiPlugin:
|
|
54
|
+
return AsyntaiPlugin('test-key', base_url='https://api.test', transport=fake.transport, **kwargs)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def names(tools: list[Any]) -> set[str]:
|
|
58
|
+
return {t.tool_name for t in tools}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def agent_with(p: AsyntaiPlugin, calls: list[tuple[str, dict[str, Any]]] | None = None) -> tuple[Agent, ScriptedModel]:
|
|
62
|
+
model = ScriptedModel(calls or [])
|
|
63
|
+
return Agent(model=model, plugins=[p], callback_handler=None), model
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def text_of(result: dict[str, Any]) -> str:
|
|
67
|
+
return result['content'][0]['text']
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_default_tools(fake: FakeAsyntai) -> None:
|
|
71
|
+
assert names(plugin(fake).tools) == READ | LEADS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_knowledge_tools_are_opt_in(fake: FakeAsyntai) -> None:
|
|
75
|
+
assert names(plugin(fake, knowledge_tools=True).tools) == READ | LEADS | WRITE
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_lead_tools_can_be_hidden(fake: FakeAsyntai) -> None:
|
|
79
|
+
assert names(plugin(fake, lead_tools=False).tools) == READ
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_model_is_offered_only_the_enabled_tools(fake: FakeAsyntai) -> None:
|
|
83
|
+
agent, model = agent_with(plugin(fake, lead_tools=False))
|
|
84
|
+
agent('Hello')
|
|
85
|
+
assert set(model.offered) == READ
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_plugin_works_in_a_real_agent_loop(fake: FakeAsyntai) -> None:
|
|
89
|
+
agent, model = agent_with(plugin(fake), [('asyntai_ask_website', {'question': 'Do you ship to Spain?'})])
|
|
90
|
+
assert 'Done.' in str(agent('Ask the site.'))
|
|
91
|
+
assert model.results[0]['status'] == 'success'
|
|
92
|
+
assert text_of(model.results[0]) == 'Answer to: Do you ship to Spain?'
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_questions_of_one_plugin_share_a_conversation(fake: FakeAsyntai) -> None:
|
|
96
|
+
p = plugin(fake)
|
|
97
|
+
agent, _ = agent_with(p, [('asyntai_ask_website', {'question': 'A'}), ('asyntai_ask_website', {'question': 'B'})])
|
|
98
|
+
agent('Ask twice.')
|
|
99
|
+
sessions = [body(r)['session_id'] for r in fake.requests if r.url.path == '/api/v1/chat/']
|
|
100
|
+
assert len(sessions) == 2
|
|
101
|
+
assert sessions[0] == sessions[1] == p.session_id
|
|
102
|
+
assert p.session_id.startswith('strands-')
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_two_plugins_have_separate_conversations(fake: FakeAsyntai) -> None:
|
|
106
|
+
assert plugin(fake).session_id != plugin(fake).session_id
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_fixed_session_id_is_used(fake: FakeAsyntai) -> None:
|
|
110
|
+
agent, _ = agent_with(plugin(fake, session_id='customer-42'), [('asyntai_ask_website', {'question': 'A'})])
|
|
111
|
+
agent('Ask.')
|
|
112
|
+
assert body(fake.last('/api/v1/chat/'))['session_id'] == 'customer-42'
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_website_id_is_sent_by_every_scoped_tool(fake: FakeAsyntai) -> None:
|
|
116
|
+
calls = [
|
|
117
|
+
('asyntai_ask_website', {'question': 'A'}),
|
|
118
|
+
('asyntai_list_knowledge', {}),
|
|
119
|
+
('asyntai_get_leads', {}),
|
|
120
|
+
('asyntai_add_text', {'title': 'T', 'content': 'C'}),
|
|
121
|
+
('asyntai_add_url', {'url': 'https://shop.test/a'}),
|
|
122
|
+
]
|
|
123
|
+
agent, _ = agent_with(plugin(fake, website_id=9, knowledge_tools=True), calls)
|
|
124
|
+
agent('Use all.')
|
|
125
|
+
assert body(fake.last('/api/v1/chat/'))['website_id'] == 9
|
|
126
|
+
assert fake.last('/api/v1/knowledge/').url.params['website_id'] == '9'
|
|
127
|
+
assert fake.last('/api/v1/leads/').url.params['website_id'] == '9'
|
|
128
|
+
assert body(fake.last('/api/v1/knowledge/text/'))['website_id'] == 9
|
|
129
|
+
assert body(fake.last('/api/v1/knowledge/url/'))['website_id'] == 9
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_lists_reach_the_model_as_json(fake: FakeAsyntai) -> None:
|
|
133
|
+
agent, model = agent_with(plugin(fake), [('asyntai_get_leads', {})])
|
|
134
|
+
agent('Leads.')
|
|
135
|
+
assert json.loads(text_of(model.results[0])) == [{'session_id': 's1', 'email': 'ana@example.test'}]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_conversation_tool(fake: FakeAsyntai) -> None:
|
|
139
|
+
agent, model = agent_with(plugin(fake), [('asyntai_get_conversation', {'session_id': 's1', 'limit': 5})])
|
|
140
|
+
agent('Read it.')
|
|
141
|
+
params = fake.last('/api/v1/conversations/').url.params
|
|
142
|
+
assert (params['session_id'], params['limit']) == ('s1', '5')
|
|
143
|
+
assert 'Spain' in text_of(model.results[0])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_empty_results_say_the_call_worked(fake: FakeAsyntai) -> None:
|
|
147
|
+
fake.empty = True
|
|
148
|
+
agent, model = agent_with(
|
|
149
|
+
plugin(fake),
|
|
150
|
+
[
|
|
151
|
+
('asyntai_get_leads', {'since': '2026-10-01'}),
|
|
152
|
+
('asyntai_list_websites', {}),
|
|
153
|
+
('asyntai_list_knowledge', {}),
|
|
154
|
+
('asyntai_get_conversation', {'session_id': 's1'}),
|
|
155
|
+
],
|
|
156
|
+
)
|
|
157
|
+
agent('Check.')
|
|
158
|
+
assert all(r['status'] == 'success' for r in model.results)
|
|
159
|
+
texts = {text_of(r) for r in model.results}
|
|
160
|
+
assert any('since 2026-10-01. The call worked.' in t for t in texts)
|
|
161
|
+
assert any('no website agents' in t for t in texts)
|
|
162
|
+
assert any('knowledge base of this website agent is empty' in t for t in texts)
|
|
163
|
+
assert any('has no messages' in t for t in texts)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_limits_are_clamped(fake: FakeAsyntai) -> None:
|
|
167
|
+
agent, _ = agent_with(
|
|
168
|
+
plugin(fake),
|
|
169
|
+
[('asyntai_get_leads', {'limit': 5000}), ('asyntai_list_knowledge', {'limit': 0})],
|
|
170
|
+
)
|
|
171
|
+
agent('Check.')
|
|
172
|
+
assert fake.last('/api/v1/leads/').url.params['limit'] == '100'
|
|
173
|
+
assert fake.last('/api/v1/knowledge/').url.params['limit'] == '1'
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_wrong_key_reaches_the_model_as_a_tool_error(fake: FakeAsyntai) -> None:
|
|
177
|
+
p = AsyntaiPlugin('wrong-key', base_url='https://api.test', transport=fake.transport)
|
|
178
|
+
agent, model = agent_with(p, [('asyntai_list_websites', {})])
|
|
179
|
+
assert 'Done.' in str(agent('List.'))
|
|
180
|
+
assert model.results[0]['status'] == 'error'
|
|
181
|
+
assert 'rejected the API key' in text_of(model.results[0])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_bad_arguments_reach_the_model_as_a_tool_error(fake: FakeAsyntai) -> None:
|
|
185
|
+
fake.status['/api/v1/chat/'] = 400
|
|
186
|
+
agent, model = agent_with(plugin(fake), [('asyntai_ask_website', {'question': 'A'})])
|
|
187
|
+
agent('Ask.')
|
|
188
|
+
assert model.results[0]['status'] == 'error'
|
|
189
|
+
assert 'fake error 400' in text_of(model.results[0])
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_write_tools_answer_with_a_sentence(fake: FakeAsyntai) -> None:
|
|
193
|
+
agent, model = agent_with(
|
|
194
|
+
plugin(fake, knowledge_tools=True),
|
|
195
|
+
[
|
|
196
|
+
('asyntai_add_text', {'title': 'Hours', 'content': '9-5'}),
|
|
197
|
+
('asyntai_add_url', {'url': 'https://shop.test/a'}),
|
|
198
|
+
],
|
|
199
|
+
)
|
|
200
|
+
agent('Store.')
|
|
201
|
+
assert [text_of(r) for r in model.results] == [
|
|
202
|
+
'Stored "Hours" in the knowledge base.',
|
|
203
|
+
'Asyntai is reading https://shop.test/a into the knowledge base.',
|
|
204
|
+
]
|
|
205
|
+
assert body(fake.last('/api/v1/knowledge/text/'))['title'] == 'Hours'
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_asyntai_tools_returns_a_plain_list_for_agents(fake: FakeAsyntai) -> None:
|
|
209
|
+
tools = asyntai_tools('test-key', base_url='https://api.test', transport=fake.transport, lead_tools=False)
|
|
210
|
+
assert names(tools) == READ
|
|
211
|
+
model = ScriptedModel([('asyntai_ask_website', {'question': 'Hi'})])
|
|
212
|
+
agent = Agent(model=model, tools=tools, callback_handler=None)
|
|
213
|
+
agent('Ask.')
|
|
214
|
+
assert text_of(model.results[0]) == 'Answer to: Hi'
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_tool_can_be_called_directly_on_the_agent(fake: FakeAsyntai) -> None:
|
|
218
|
+
agent, _ = agent_with(plugin(fake))
|
|
219
|
+
result = agent.tool.asyntai_ask_website(question='Hi')
|
|
220
|
+
assert result['status'] == 'success'
|
|
221
|
+
assert text_of(result) == 'Answer to: Hi'
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_tool_specs_describe_the_arguments(fake: FakeAsyntai) -> None:
|
|
225
|
+
specs = {t.tool_name: t.tool_spec for t in plugin(fake, knowledge_tools=True).tools}
|
|
226
|
+
ask = specs['asyntai_ask_website']['inputSchema']['json']
|
|
227
|
+
assert list(ask['properties']) == ['question']
|
|
228
|
+
assert ask['required'] == ['question']
|
|
229
|
+
assert 'any language' in ask['properties']['question']['description']
|
|
230
|
+
leads = specs['asyntai_get_leads']['inputSchema']['json']
|
|
231
|
+
assert not leads.get('required')
|