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.
@@ -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,7 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
7
+ catalog-entry.yaml
@@ -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')