conversimple-sdk 0.2.3__tar.gz → 0.2.4__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.
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/PKG-INFO +15 -10
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/README.md +13 -4
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/agent.py +102 -11
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/config.py +1 -1
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/connection.py +34 -17
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/dispatcher.py +22 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/tools.py +2 -2
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/PKG-INFO +15 -10
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/SOURCES.txt +2 -2
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/entry_points.txt +1 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/setup.py +3 -4
- conversimple_sdk-0.2.4/tests/test_dispatcher_session.py +120 -0
- conversimple_sdk-0.2.3/LICENSE +0 -21
- conversimple_sdk-0.2.3/tests/test_platform_api_client.py +0 -460
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/__init__.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/__init__.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/client.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/__init__.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/agents.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/api_keys.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/deployments.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/exceptions.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/models.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/callbacks.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/utils.py +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/dependency_links.txt +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/not-zip-safe +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/requires.txt +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/top_level.txt +0 -0
- {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conversimple-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Python SDK for Conversimple Conversational AI Platform
|
|
5
5
|
Home-page: https://github.com/conversimple/conversimple-sdk
|
|
6
6
|
Author: Conversimple
|
|
@@ -16,14 +16,11 @@ Classifier: Topic :: Communications :: Chat
|
|
|
16
16
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
17
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
21
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.10
|
|
25
23
|
Description-Content-Type: text/markdown
|
|
26
|
-
License-File: LICENSE
|
|
27
24
|
Requires-Dist: websockets>=12.0
|
|
28
25
|
Requires-Dist: aiofiles>=23.0
|
|
29
26
|
Requires-Dist: aiohttp>=3.8.0
|
|
@@ -45,7 +42,6 @@ Dynamic: description
|
|
|
45
42
|
Dynamic: description-content-type
|
|
46
43
|
Dynamic: home-page
|
|
47
44
|
Dynamic: keywords
|
|
48
|
-
Dynamic: license-file
|
|
49
45
|
Dynamic: project-url
|
|
50
46
|
Dynamic: provides-extra
|
|
51
47
|
Dynamic: requires-dist
|
|
@@ -96,7 +92,7 @@ class MyAgent(ConversimpleAgent):
|
|
|
96
92
|
Use the dispatcher to discover your agent modules and launch per-conversation instances automatically:
|
|
97
93
|
|
|
98
94
|
```bash
|
|
99
|
-
|
|
95
|
+
conversimple-dispatcher \
|
|
100
96
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
101
97
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
102
98
|
--search-path ./agents
|
|
@@ -224,7 +220,7 @@ class BillingAgent(ConversimpleAgent):
|
|
|
224
220
|
Point the dispatcher at a directory of agent modules (typically your project root):
|
|
225
221
|
|
|
226
222
|
```bash
|
|
227
|
-
|
|
223
|
+
conversimple-dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
|
|
228
224
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
229
225
|
--search-path ./agents
|
|
230
226
|
```
|
|
@@ -251,6 +247,15 @@ class BusinessAgent(ConversimpleAgent):
|
|
|
251
247
|
return {"sent": True, "message_id": result.id}
|
|
252
248
|
```
|
|
253
249
|
|
|
250
|
+
The 0.2.4 source re-registers conversation tools after a WebSocket reconnect.
|
|
251
|
+
Tool calls run independently of the receive loop, so one slow call does not
|
|
252
|
+
block the next message. A repeated call ID returns its previous result without
|
|
253
|
+
re-executing the function; the cache is bounded to 1,024 results per agent
|
|
254
|
+
process. Tool execution has a default 30-second timeout, capped at five
|
|
255
|
+
minutes. Synchronous functions run in a worker thread, so a timed-out function
|
|
256
|
+
may finish its side effect after the timeout response; design externally
|
|
257
|
+
mutating tools to be idempotent. Python 3.10 or newer is required.
|
|
258
|
+
|
|
254
259
|
### Event Callbacks
|
|
255
260
|
|
|
256
261
|
Handle conversation lifecycle events:
|
|
@@ -353,7 +358,7 @@ Configuration is loaded in this order (first match wins):
|
|
|
353
358
|
**Remote Platform Configuration:**
|
|
354
359
|
```bash
|
|
355
360
|
export CONVERSIMPLE_API_ENDPOINT="https://api.conversimple.com"
|
|
356
|
-
export CONVERSIMPLE_PLATFORM_URL="wss://
|
|
361
|
+
export CONVERSIMPLE_PLATFORM_URL="wss://app.conversimple.com/customer_sdk/websocket"
|
|
357
362
|
export CONVERSIMPLE_API_KEY="prod-api-key"
|
|
358
363
|
export CONVERSIMPLE_ENABLE_CIRCUIT_BREAKER="true"
|
|
359
364
|
```
|
|
@@ -467,7 +472,7 @@ agent = ConversimpleAgent(
|
|
|
467
472
|
Example agents live in `examples/` and are discovered automatically when you point the dispatcher at that directory:
|
|
468
473
|
|
|
469
474
|
```bash
|
|
470
|
-
|
|
475
|
+
conversimple-dispatcher \
|
|
471
476
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
472
477
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
473
478
|
--search-path ./examples
|
|
@@ -42,7 +42,7 @@ class MyAgent(ConversimpleAgent):
|
|
|
42
42
|
Use the dispatcher to discover your agent modules and launch per-conversation instances automatically:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
|
|
45
|
+
conversimple-dispatcher \
|
|
46
46
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
47
47
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
48
48
|
--search-path ./agents
|
|
@@ -170,7 +170,7 @@ class BillingAgent(ConversimpleAgent):
|
|
|
170
170
|
Point the dispatcher at a directory of agent modules (typically your project root):
|
|
171
171
|
|
|
172
172
|
```bash
|
|
173
|
-
|
|
173
|
+
conversimple-dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
|
|
174
174
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
175
175
|
--search-path ./agents
|
|
176
176
|
```
|
|
@@ -197,6 +197,15 @@ class BusinessAgent(ConversimpleAgent):
|
|
|
197
197
|
return {"sent": True, "message_id": result.id}
|
|
198
198
|
```
|
|
199
199
|
|
|
200
|
+
The 0.2.4 source re-registers conversation tools after a WebSocket reconnect.
|
|
201
|
+
Tool calls run independently of the receive loop, so one slow call does not
|
|
202
|
+
block the next message. A repeated call ID returns its previous result without
|
|
203
|
+
re-executing the function; the cache is bounded to 1,024 results per agent
|
|
204
|
+
process. Tool execution has a default 30-second timeout, capped at five
|
|
205
|
+
minutes. Synchronous functions run in a worker thread, so a timed-out function
|
|
206
|
+
may finish its side effect after the timeout response; design externally
|
|
207
|
+
mutating tools to be idempotent. Python 3.10 or newer is required.
|
|
208
|
+
|
|
200
209
|
### Event Callbacks
|
|
201
210
|
|
|
202
211
|
Handle conversation lifecycle events:
|
|
@@ -299,7 +308,7 @@ Configuration is loaded in this order (first match wins):
|
|
|
299
308
|
**Remote Platform Configuration:**
|
|
300
309
|
```bash
|
|
301
310
|
export CONVERSIMPLE_API_ENDPOINT="https://api.conversimple.com"
|
|
302
|
-
export CONVERSIMPLE_PLATFORM_URL="wss://
|
|
311
|
+
export CONVERSIMPLE_PLATFORM_URL="wss://app.conversimple.com/customer_sdk/websocket"
|
|
303
312
|
export CONVERSIMPLE_API_KEY="prod-api-key"
|
|
304
313
|
export CONVERSIMPLE_ENABLE_CIRCUIT_BREAKER="true"
|
|
305
314
|
```
|
|
@@ -413,7 +422,7 @@ agent = ConversimpleAgent(
|
|
|
413
422
|
Example agents live in `examples/` and are discovered automatically when you point the dispatcher at that directory:
|
|
414
423
|
|
|
415
424
|
```bash
|
|
416
|
-
|
|
425
|
+
conversimple-dispatcher \
|
|
417
426
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
418
427
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
419
428
|
--search-path ./examples
|
|
@@ -13,6 +13,7 @@ import json
|
|
|
13
13
|
import logging
|
|
14
14
|
import os
|
|
15
15
|
import uuid
|
|
16
|
+
from collections import OrderedDict
|
|
16
17
|
from typing import Dict, List, Optional, Callable, Any, Union
|
|
17
18
|
from datetime import datetime
|
|
18
19
|
import datetime as dt
|
|
@@ -107,7 +108,11 @@ class ConversimpleAgent:
|
|
|
107
108
|
self.conversation_id: Optional[str] = None
|
|
108
109
|
self.connection_state = "disconnected"
|
|
109
110
|
self.registered_tools: List[Dict] = []
|
|
111
|
+
self.registered_conversations = set()
|
|
112
|
+
self.ended_conversations = set()
|
|
110
113
|
self.pending_tool_calls: Dict[str, ToolCall] = {}
|
|
114
|
+
self._tool_tasks: Dict[str, asyncio.Task] = {}
|
|
115
|
+
self._completed_tool_results = OrderedDict()
|
|
111
116
|
|
|
112
117
|
# Event callbacks
|
|
113
118
|
self.callbacks: Dict[str, Callable] = {}
|
|
@@ -139,14 +144,17 @@ class ConversimpleAgent:
|
|
|
139
144
|
|
|
140
145
|
# Connect to platform
|
|
141
146
|
await self.connection.connect()
|
|
147
|
+
if not self.connection.connected or not self.connection.channel_joined:
|
|
148
|
+
raise RuntimeError("SDK channel did not connect")
|
|
142
149
|
self.connection_state = "connected"
|
|
143
150
|
|
|
144
151
|
# Auto-register tools from decorated methods
|
|
145
152
|
auto_register_tools(self)
|
|
146
153
|
|
|
147
|
-
#
|
|
148
|
-
# Don't register immediately since we don't have a real conversation ID yet
|
|
154
|
+
# A dispatcher already knows the real conversation ID at startup.
|
|
149
155
|
await self._register_tools()
|
|
156
|
+
if conversation_id and self.registered_tools:
|
|
157
|
+
await self._register_conversation_tools(conversation_id, self.registered_tools)
|
|
150
158
|
|
|
151
159
|
logger.info(f"Agent started successfully for conversation: {self.conversation_id}")
|
|
152
160
|
|
|
@@ -155,11 +163,16 @@ class ConversimpleAgent:
|
|
|
155
163
|
logger.info(f"Stopping agent for conversation: {self.conversation_id}")
|
|
156
164
|
|
|
157
165
|
self.connection_state = "disconnecting"
|
|
166
|
+
for task in self._tool_tasks.values():
|
|
167
|
+
task.cancel()
|
|
168
|
+
if self._tool_tasks:
|
|
169
|
+
await asyncio.gather(*self._tool_tasks.values(), return_exceptions=True)
|
|
170
|
+
self._tool_tasks.clear()
|
|
158
171
|
await self.connection.disconnect()
|
|
159
172
|
self.connection_state = "disconnected"
|
|
160
173
|
|
|
161
174
|
# Trigger conversation ended callback
|
|
162
|
-
await self.
|
|
175
|
+
await self._notify_conversation_ended(self.conversation_id)
|
|
163
176
|
|
|
164
177
|
async def _register_tools(self) -> None:
|
|
165
178
|
"""Store discovered tools for registration when conversations start."""
|
|
@@ -174,12 +187,15 @@ class ConversimpleAgent:
|
|
|
174
187
|
|
|
175
188
|
async def _register_conversation_tools(self, conversation_id: str, tools: list) -> None:
|
|
176
189
|
"""Register tools for a specific conversation."""
|
|
190
|
+
if conversation_id in self.registered_conversations:
|
|
191
|
+
return
|
|
177
192
|
message = {
|
|
178
193
|
"conversation_id": conversation_id,
|
|
179
194
|
"tools": tools
|
|
180
195
|
}
|
|
181
196
|
|
|
182
197
|
await self.connection.send_message("register_conversation_tools", message)
|
|
198
|
+
self.registered_conversations.add(conversation_id)
|
|
183
199
|
logger.info(f"Registered {len(tools)} tools for conversation {conversation_id}")
|
|
184
200
|
|
|
185
201
|
async def _handle_platform_message(self, event: str, payload: Dict) -> None:
|
|
@@ -197,6 +213,19 @@ class ConversimpleAgent:
|
|
|
197
213
|
"conversation_ready": self._handle_conversation_ready
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
if event == "tool_call_request":
|
|
217
|
+
call_id = payload.get("call_id")
|
|
218
|
+
if call_id in self._tool_tasks:
|
|
219
|
+
logger.info("Ignoring duplicate in-flight tool call: %s", call_id)
|
|
220
|
+
return
|
|
221
|
+
task = asyncio.create_task(self._handle_tool_call_request(payload))
|
|
222
|
+
if call_id:
|
|
223
|
+
self._tool_tasks[call_id] = task
|
|
224
|
+
task.add_done_callback(
|
|
225
|
+
lambda done, key=call_id: self._finish_tool_task(key, done)
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
|
|
200
229
|
handler = handlers.get(event)
|
|
201
230
|
if handler:
|
|
202
231
|
try:
|
|
@@ -256,6 +285,17 @@ class ConversimpleAgent:
|
|
|
256
285
|
if not call_id or not tool_name:
|
|
257
286
|
await self._send_tool_error(call_id or "unknown", "Missing call_id or tool_name")
|
|
258
287
|
return
|
|
288
|
+
|
|
289
|
+
if call_id in self._completed_tool_results:
|
|
290
|
+
event, response = self._completed_tool_results[call_id]
|
|
291
|
+
await self.connection.send_message(event, response)
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
timeout = payload.get("timeout_seconds", 30)
|
|
295
|
+
try:
|
|
296
|
+
timeout = max(0.1, min(float(timeout), 300.0))
|
|
297
|
+
except (TypeError, ValueError):
|
|
298
|
+
timeout = 30.0
|
|
259
299
|
|
|
260
300
|
# Create tool call object
|
|
261
301
|
tool_call = ToolCall(
|
|
@@ -263,7 +303,7 @@ class ConversimpleAgent:
|
|
|
263
303
|
tool_name=tool_name,
|
|
264
304
|
arguments=arguments,
|
|
265
305
|
conversation_id=self.conversation_id,
|
|
266
|
-
timeout_seconds=
|
|
306
|
+
timeout_seconds=timeout
|
|
267
307
|
)
|
|
268
308
|
|
|
269
309
|
# Store as pending
|
|
@@ -274,21 +314,55 @@ class ConversimpleAgent:
|
|
|
274
314
|
|
|
275
315
|
try:
|
|
276
316
|
# Execute the tool
|
|
277
|
-
result = await
|
|
317
|
+
result = await asyncio.wait_for(
|
|
318
|
+
self.tool_registry.execute_tool(tool_name, arguments), timeout=timeout
|
|
319
|
+
)
|
|
278
320
|
|
|
279
321
|
# Send successful result
|
|
280
|
-
|
|
322
|
+
response = {
|
|
323
|
+
"call_id": call_id,
|
|
324
|
+
"conversation_id": self.conversation_id,
|
|
325
|
+
"result": result,
|
|
326
|
+
"tool_name": tool_name,
|
|
327
|
+
}
|
|
328
|
+
self._remember_tool_result(call_id, "tool_call_response", response)
|
|
329
|
+
await self._send_tool_result(call_id, result, tool_name)
|
|
281
330
|
|
|
282
331
|
# Trigger completion callback
|
|
283
332
|
await self.callback_manager.trigger_tool_completed(call_id, result)
|
|
284
333
|
|
|
334
|
+
except asyncio.CancelledError:
|
|
335
|
+
raise
|
|
285
336
|
except Exception as e:
|
|
286
337
|
logger.error(f"Tool execution failed for {tool_name}: {e}")
|
|
287
|
-
|
|
338
|
+
response = {
|
|
339
|
+
"call_id": call_id,
|
|
340
|
+
"conversation_id": self.conversation_id,
|
|
341
|
+
"error": {
|
|
342
|
+
"message": "Tool execution timed out" if isinstance(e, TimeoutError) else str(e),
|
|
343
|
+
"timestamp": datetime.now(dt.timezone.utc).isoformat(),
|
|
344
|
+
},
|
|
345
|
+
"tool_name": tool_name,
|
|
346
|
+
}
|
|
347
|
+
self._remember_tool_result(call_id, "tool_call_error", response)
|
|
348
|
+
await self._send_tool_error(call_id, response["error"]["message"], tool_name)
|
|
288
349
|
finally:
|
|
289
350
|
# Clean up pending call
|
|
290
351
|
self.pending_tool_calls.pop(call_id, None)
|
|
291
352
|
|
|
353
|
+
def _remember_tool_result(self, call_id: str, event: str, payload: Dict) -> None:
|
|
354
|
+
self._completed_tool_results[call_id] = (event, payload)
|
|
355
|
+
self._completed_tool_results.move_to_end(call_id)
|
|
356
|
+
if len(self._completed_tool_results) > 1024:
|
|
357
|
+
self._completed_tool_results.popitem(last=False)
|
|
358
|
+
|
|
359
|
+
def _finish_tool_task(self, call_id: str, task: asyncio.Task) -> None:
|
|
360
|
+
self._tool_tasks.pop(call_id, None)
|
|
361
|
+
if not task.cancelled():
|
|
362
|
+
error = task.exception()
|
|
363
|
+
if error:
|
|
364
|
+
logger.error("Tool call %s could not deliver its result: %s", call_id, error)
|
|
365
|
+
|
|
292
366
|
async def _handle_conversation_lifecycle(self, payload: Dict) -> None:
|
|
293
367
|
"""Handle conversation lifecycle events."""
|
|
294
368
|
event = payload.get("event")
|
|
@@ -312,10 +386,15 @@ class ConversimpleAgent:
|
|
|
312
386
|
await self.callback_manager.trigger_conversation_started(conversation_id, payload)
|
|
313
387
|
elif event == "conversation_ended":
|
|
314
388
|
logger.info(f"🏁 Conversation ended: {conversation_id}")
|
|
315
|
-
await self.
|
|
389
|
+
await self._notify_conversation_ended(conversation_id, payload)
|
|
316
390
|
else:
|
|
317
391
|
logger.warning(f"❓ Unknown lifecycle event: {event}")
|
|
318
392
|
|
|
393
|
+
async def _notify_conversation_ended(self, conversation_id: str, payload: Optional[Dict] = None) -> None:
|
|
394
|
+
if conversation_id and conversation_id not in self.ended_conversations:
|
|
395
|
+
self.ended_conversations.add(conversation_id)
|
|
396
|
+
await self.callback_manager.trigger_conversation_ended(conversation_id, payload)
|
|
397
|
+
|
|
319
398
|
async def _handle_hook_event(self, payload: Dict) -> None:
|
|
320
399
|
"""Handle hook events from platform."""
|
|
321
400
|
event_type = payload.get("event_type")
|
|
@@ -344,6 +423,12 @@ class ConversimpleAgent:
|
|
|
344
423
|
if event == "connected":
|
|
345
424
|
self.connection_state = "connected"
|
|
346
425
|
logger.info("✅ Connection established successfully")
|
|
426
|
+
# A new channel process does not retain per-conversation tools.
|
|
427
|
+
# The first connect has no discovered tools yet; later connects do.
|
|
428
|
+
if self.registered_tools:
|
|
429
|
+
for conversation_id in list(self.registered_conversations):
|
|
430
|
+
self.registered_conversations.discard(conversation_id)
|
|
431
|
+
await self._register_conversation_tools(conversation_id, self.registered_tools)
|
|
347
432
|
|
|
348
433
|
elif event == "disconnected":
|
|
349
434
|
self.connection_state = "disconnected"
|
|
@@ -375,25 +460,31 @@ class ConversimpleAgent:
|
|
|
375
460
|
logger.error(f"❌ Connection error (retrying): {data}")
|
|
376
461
|
logger.info("🔄 Automatic reconnection will be attempted")
|
|
377
462
|
|
|
378
|
-
async def _send_tool_result(self, call_id: str, result: Any) -> None:
|
|
463
|
+
async def _send_tool_result(self, call_id: str, result: Any, tool_name: Optional[str] = None) -> None:
|
|
379
464
|
"""Send tool execution result to platform."""
|
|
380
465
|
message = {
|
|
381
466
|
"call_id": call_id,
|
|
467
|
+
"conversation_id": self.conversation_id,
|
|
382
468
|
"result": result
|
|
383
469
|
}
|
|
470
|
+
if tool_name:
|
|
471
|
+
message["tool_name"] = tool_name
|
|
384
472
|
|
|
385
473
|
await self.connection.send_message("tool_call_response", message)
|
|
386
474
|
logger.debug(f"Sent tool result for call: {call_id}")
|
|
387
475
|
|
|
388
|
-
async def _send_tool_error(self, call_id: str, error: str) -> None:
|
|
476
|
+
async def _send_tool_error(self, call_id: str, error: str, tool_name: Optional[str] = None) -> None:
|
|
389
477
|
"""Send tool execution error to platform."""
|
|
390
478
|
message = {
|
|
391
479
|
"call_id": call_id,
|
|
480
|
+
"conversation_id": self.conversation_id,
|
|
392
481
|
"error": {
|
|
393
482
|
"message": error,
|
|
394
483
|
"timestamp": datetime.now(dt.timezone.utc).isoformat()
|
|
395
484
|
}
|
|
396
485
|
}
|
|
486
|
+
if tool_name:
|
|
487
|
+
message["tool_name"] = tool_name
|
|
397
488
|
|
|
398
489
|
await self.connection.send_message("tool_call_error", message)
|
|
399
490
|
logger.debug(f"Sent tool error for call: {call_id}")
|
|
@@ -426,4 +517,4 @@ class ConversimpleAgent:
|
|
|
426
517
|
|
|
427
518
|
def on_config_update(self, callback: Callable[[Dict], None]) -> None:
|
|
428
519
|
"""Register callback for configuration updates."""
|
|
429
|
-
self.callback_manager.on_config_update = callback
|
|
520
|
+
self.callback_manager.on_config_update = callback
|
|
@@ -17,7 +17,7 @@ class Config:
|
|
|
17
17
|
# Platform WebSocket Configuration
|
|
18
18
|
PLATFORM_URL: str = os.getenv(
|
|
19
19
|
"CONVERSIMPLE_PLATFORM_URL",
|
|
20
|
-
"wss://
|
|
20
|
+
"wss://app.conversimple.com/customer_sdk/websocket"
|
|
21
21
|
)
|
|
22
22
|
"""Platform WebSocket URL for agent communication."""
|
|
23
23
|
|
|
@@ -111,7 +111,7 @@ class WebSocketConnection:
|
|
|
111
111
|
"""Set handler for connection events."""
|
|
112
112
|
self.connection_handler = handler
|
|
113
113
|
|
|
114
|
-
async def connect(self) -> None:
|
|
114
|
+
async def connect(self, recover: bool = True) -> None:
|
|
115
115
|
"""Connect to the platform WebSocket."""
|
|
116
116
|
connection_params = {
|
|
117
117
|
"customer_id": self.customer_id,
|
|
@@ -146,7 +146,15 @@ class WebSocketConnection:
|
|
|
146
146
|
|
|
147
147
|
except Exception as e:
|
|
148
148
|
logger.error(f"Failed to connect to platform: {e}")
|
|
149
|
-
|
|
149
|
+
self.connected = False
|
|
150
|
+
self.channel_joined = False
|
|
151
|
+
if self.websocket:
|
|
152
|
+
await self.websocket.close()
|
|
153
|
+
self.websocket = None
|
|
154
|
+
if recover:
|
|
155
|
+
await self._handle_connection_error(e)
|
|
156
|
+
else:
|
|
157
|
+
raise
|
|
150
158
|
|
|
151
159
|
async def disconnect(self) -> None:
|
|
152
160
|
"""Disconnect from the platform."""
|
|
@@ -248,19 +256,24 @@ class WebSocketConnection:
|
|
|
248
256
|
|
|
249
257
|
async def _stop_background_tasks(self) -> None:
|
|
250
258
|
"""Stop background tasks."""
|
|
259
|
+
current_task = asyncio.current_task()
|
|
251
260
|
if self.heartbeat_task:
|
|
252
|
-
self.heartbeat_task
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
261
|
+
if self.heartbeat_task is not current_task:
|
|
262
|
+
self.heartbeat_task.cancel()
|
|
263
|
+
try:
|
|
264
|
+
await self.heartbeat_task
|
|
265
|
+
except asyncio.CancelledError:
|
|
266
|
+
pass
|
|
267
|
+
self.heartbeat_task = None
|
|
268
|
+
|
|
258
269
|
if self.message_task:
|
|
259
|
-
self.message_task
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
if self.message_task is not current_task:
|
|
271
|
+
self.message_task.cancel()
|
|
272
|
+
try:
|
|
273
|
+
await self.message_task
|
|
274
|
+
except asyncio.CancelledError:
|
|
275
|
+
pass
|
|
276
|
+
self.message_task = None
|
|
264
277
|
|
|
265
278
|
async def _heartbeat_loop(self) -> None:
|
|
266
279
|
"""Send periodic heartbeat messages to platform."""
|
|
@@ -284,8 +297,10 @@ class WebSocketConnection:
|
|
|
284
297
|
message = await self.websocket.recv()
|
|
285
298
|
await self._handle_message(message)
|
|
286
299
|
|
|
287
|
-
except (ConnectionClosedError, ConnectionClosedOK):
|
|
288
|
-
logger.
|
|
300
|
+
except (ConnectionClosedError, ConnectionClosedOK) as error:
|
|
301
|
+
logger.warning("WebSocket connection closed: %s", error)
|
|
302
|
+
if self.connected:
|
|
303
|
+
await self._handle_connection_error(error)
|
|
289
304
|
break
|
|
290
305
|
except asyncio.CancelledError:
|
|
291
306
|
break
|
|
@@ -415,7 +430,9 @@ class WebSocketConnection:
|
|
|
415
430
|
return
|
|
416
431
|
|
|
417
432
|
try:
|
|
418
|
-
await self.connect()
|
|
433
|
+
await self.connect(recover=False)
|
|
434
|
+
if not self.connected or not self.channel_joined:
|
|
435
|
+
raise TransientConnectionError("SDK channel did not reconnect")
|
|
419
436
|
# Reset retry state on successful connection
|
|
420
437
|
self.reconnect_attempts = 0
|
|
421
438
|
self.first_retry_time = None
|
|
@@ -543,4 +560,4 @@ class WebSocketConnection:
|
|
|
543
560
|
jitter = backoff * 0.2 * (random.random() * 2 - 1)
|
|
544
561
|
backoff = backoff + jitter
|
|
545
562
|
|
|
546
|
-
return max(1.0, backoff) # Ensure minimum 1 second
|
|
563
|
+
return max(1.0, backoff) # Ensure minimum 1 second
|
|
@@ -9,10 +9,12 @@ inherit from `ConversimpleAgent` and expose an `agent_id` attribute.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import asyncio
|
|
12
|
+
import argparse
|
|
12
13
|
import contextlib
|
|
13
14
|
import importlib.util
|
|
14
15
|
import inspect
|
|
15
16
|
import logging
|
|
17
|
+
import os
|
|
16
18
|
import sys
|
|
17
19
|
from dataclasses import dataclass
|
|
18
20
|
from pathlib import Path
|
|
@@ -320,3 +322,23 @@ async def run_dispatcher(api_key: str, platform_url: str, search_path: Optional[
|
|
|
320
322
|
logger.info("Keyboard interrupt received, stopping dispatcher")
|
|
321
323
|
finally:
|
|
322
324
|
await dispatcher.stop()
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def main() -> None:
|
|
328
|
+
parser = argparse.ArgumentParser(description="Run a Conversimple SDK dispatcher")
|
|
329
|
+
parser.add_argument("--api-key", default=os.getenv("CONVERSIMPLE_API_KEY"))
|
|
330
|
+
parser.add_argument("--platform-url", default=Config.PLATFORM_URL)
|
|
331
|
+
parser.add_argument("--search-path", type=Path, default=Path.cwd())
|
|
332
|
+
args = parser.parse_args()
|
|
333
|
+
if not args.api_key:
|
|
334
|
+
parser.error("provide --api-key or set CONVERSIMPLE_API_KEY")
|
|
335
|
+
|
|
336
|
+
logging.basicConfig(level=logging.INFO)
|
|
337
|
+
try:
|
|
338
|
+
asyncio.run(run_dispatcher(args.api_key, args.platform_url, args.search_path))
|
|
339
|
+
except KeyboardInterrupt:
|
|
340
|
+
pass
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
if __name__ == "__main__":
|
|
344
|
+
main()
|
|
@@ -98,7 +98,7 @@ class ToolRegistry:
|
|
|
98
98
|
|
|
99
99
|
try:
|
|
100
100
|
# Execute synchronous function
|
|
101
|
-
result = func
|
|
101
|
+
result = await asyncio.to_thread(func, **arguments)
|
|
102
102
|
logger.debug(f"Sync tool {tool_name} executed successfully")
|
|
103
103
|
return result
|
|
104
104
|
|
|
@@ -310,4 +310,4 @@ def auto_register_tools(agent_instance: Any) -> None:
|
|
|
310
310
|
else:
|
|
311
311
|
logger.warning(f"Unknown tool type: {tool_type}")
|
|
312
312
|
|
|
313
|
-
logger.info(f"Auto-registered {len(discovered_tools)} tools")
|
|
313
|
+
logger.info(f"Auto-registered {len(discovered_tools)} tools")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conversimple-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Python SDK for Conversimple Conversational AI Platform
|
|
5
5
|
Home-page: https://github.com/conversimple/conversimple-sdk
|
|
6
6
|
Author: Conversimple
|
|
@@ -16,14 +16,11 @@ Classifier: Topic :: Communications :: Chat
|
|
|
16
16
|
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
17
17
|
Classifier: License :: OSI Approved :: MIT License
|
|
18
18
|
Classifier: Programming Language :: Python :: 3
|
|
19
|
-
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
-
Classifier: Programming Language :: Python :: 3.9
|
|
21
19
|
Classifier: Programming Language :: Python :: 3.10
|
|
22
20
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
21
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.10
|
|
25
23
|
Description-Content-Type: text/markdown
|
|
26
|
-
License-File: LICENSE
|
|
27
24
|
Requires-Dist: websockets>=12.0
|
|
28
25
|
Requires-Dist: aiofiles>=23.0
|
|
29
26
|
Requires-Dist: aiohttp>=3.8.0
|
|
@@ -45,7 +42,6 @@ Dynamic: description
|
|
|
45
42
|
Dynamic: description-content-type
|
|
46
43
|
Dynamic: home-page
|
|
47
44
|
Dynamic: keywords
|
|
48
|
-
Dynamic: license-file
|
|
49
45
|
Dynamic: project-url
|
|
50
46
|
Dynamic: provides-extra
|
|
51
47
|
Dynamic: requires-dist
|
|
@@ -96,7 +92,7 @@ class MyAgent(ConversimpleAgent):
|
|
|
96
92
|
Use the dispatcher to discover your agent modules and launch per-conversation instances automatically:
|
|
97
93
|
|
|
98
94
|
```bash
|
|
99
|
-
|
|
95
|
+
conversimple-dispatcher \
|
|
100
96
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
101
97
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
102
98
|
--search-path ./agents
|
|
@@ -224,7 +220,7 @@ class BillingAgent(ConversimpleAgent):
|
|
|
224
220
|
Point the dispatcher at a directory of agent modules (typically your project root):
|
|
225
221
|
|
|
226
222
|
```bash
|
|
227
|
-
|
|
223
|
+
conversimple-dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
|
|
228
224
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
229
225
|
--search-path ./agents
|
|
230
226
|
```
|
|
@@ -251,6 +247,15 @@ class BusinessAgent(ConversimpleAgent):
|
|
|
251
247
|
return {"sent": True, "message_id": result.id}
|
|
252
248
|
```
|
|
253
249
|
|
|
250
|
+
The 0.2.4 source re-registers conversation tools after a WebSocket reconnect.
|
|
251
|
+
Tool calls run independently of the receive loop, so one slow call does not
|
|
252
|
+
block the next message. A repeated call ID returns its previous result without
|
|
253
|
+
re-executing the function; the cache is bounded to 1,024 results per agent
|
|
254
|
+
process. Tool execution has a default 30-second timeout, capped at five
|
|
255
|
+
minutes. Synchronous functions run in a worker thread, so a timed-out function
|
|
256
|
+
may finish its side effect after the timeout response; design externally
|
|
257
|
+
mutating tools to be idempotent. Python 3.10 or newer is required.
|
|
258
|
+
|
|
254
259
|
### Event Callbacks
|
|
255
260
|
|
|
256
261
|
Handle conversation lifecycle events:
|
|
@@ -353,7 +358,7 @@ Configuration is loaded in this order (first match wins):
|
|
|
353
358
|
**Remote Platform Configuration:**
|
|
354
359
|
```bash
|
|
355
360
|
export CONVERSIMPLE_API_ENDPOINT="https://api.conversimple.com"
|
|
356
|
-
export CONVERSIMPLE_PLATFORM_URL="wss://
|
|
361
|
+
export CONVERSIMPLE_PLATFORM_URL="wss://app.conversimple.com/customer_sdk/websocket"
|
|
357
362
|
export CONVERSIMPLE_API_KEY="prod-api-key"
|
|
358
363
|
export CONVERSIMPLE_ENABLE_CIRCUIT_BREAKER="true"
|
|
359
364
|
```
|
|
@@ -467,7 +472,7 @@ agent = ConversimpleAgent(
|
|
|
467
472
|
Example agents live in `examples/` and are discovered automatically when you point the dispatcher at that directory:
|
|
468
473
|
|
|
469
474
|
```bash
|
|
470
|
-
|
|
475
|
+
conversimple-dispatcher \
|
|
471
476
|
--api-key "$CONVERSIMPLE_API_KEY" \
|
|
472
477
|
--platform-url "$CONVERSIMPLE_PLATFORM_URL" \
|
|
473
478
|
--search-path ./examples
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
LICENSE
|
|
2
1
|
README.md
|
|
2
|
+
setup.cfg
|
|
3
3
|
setup.py
|
|
4
4
|
conversimple/__init__.py
|
|
5
5
|
conversimple/agent.py
|
|
@@ -24,4 +24,4 @@ conversimple_sdk.egg-info/entry_points.txt
|
|
|
24
24
|
conversimple_sdk.egg-info/not-zip-safe
|
|
25
25
|
conversimple_sdk.egg-info/requires.txt
|
|
26
26
|
conversimple_sdk.egg-info/top_level.txt
|
|
27
|
-
tests/
|
|
27
|
+
tests/test_dispatcher_session.py
|
|
@@ -16,7 +16,7 @@ except FileNotFoundError:
|
|
|
16
16
|
|
|
17
17
|
setup(
|
|
18
18
|
name="conversimple-sdk",
|
|
19
|
-
version="0.2.
|
|
19
|
+
version="0.2.4",
|
|
20
20
|
author="Conversimple",
|
|
21
21
|
author_email="support@conversimple.com",
|
|
22
22
|
description="Python SDK for Conversimple Conversational AI Platform",
|
|
@@ -36,14 +36,12 @@ setup(
|
|
|
36
36
|
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
|
|
37
37
|
"License :: OSI Approved :: MIT License",
|
|
38
38
|
"Programming Language :: Python :: 3",
|
|
39
|
-
"Programming Language :: Python :: 3.8",
|
|
40
|
-
"Programming Language :: Python :: 3.9",
|
|
41
39
|
"Programming Language :: Python :: 3.10",
|
|
42
40
|
"Programming Language :: Python :: 3.11",
|
|
43
41
|
"Programming Language :: Python :: 3.12",
|
|
44
42
|
],
|
|
45
43
|
packages=find_packages(),
|
|
46
|
-
python_requires=">=3.
|
|
44
|
+
python_requires=">=3.10",
|
|
47
45
|
install_requires=[
|
|
48
46
|
"websockets>=12.0",
|
|
49
47
|
"aiofiles>=23.0",
|
|
@@ -66,6 +64,7 @@ setup(
|
|
|
66
64
|
},
|
|
67
65
|
entry_points={
|
|
68
66
|
"console_scripts": [
|
|
67
|
+
"conversimple-dispatcher=conversimple.dispatcher:main",
|
|
69
68
|
"conversimple-example=examples.simple_agent:main",
|
|
70
69
|
],
|
|
71
70
|
},
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from conversimple import ConversimpleAgent, tool, tool_async
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DemoAgent(ConversimpleAgent):
|
|
8
|
+
@tool("Return a sample answer")
|
|
9
|
+
def sample(self):
|
|
10
|
+
self.sample_calls = getattr(self, "sample_calls", 0) + 1
|
|
11
|
+
return {"answer": "ok"}
|
|
12
|
+
|
|
13
|
+
@tool_async("Wait longer than the tool deadline")
|
|
14
|
+
async def stall(self):
|
|
15
|
+
await asyncio.sleep(1)
|
|
16
|
+
return {"answer": "late"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeConnection:
|
|
20
|
+
def __init__(self):
|
|
21
|
+
self.messages = []
|
|
22
|
+
self.connected = False
|
|
23
|
+
self.channel_joined = False
|
|
24
|
+
|
|
25
|
+
def set_message_handler(self, handler):
|
|
26
|
+
self.message_handler = handler
|
|
27
|
+
|
|
28
|
+
def set_connection_handler(self, handler):
|
|
29
|
+
self.connection_handler = handler
|
|
30
|
+
|
|
31
|
+
async def connect(self):
|
|
32
|
+
self.connected = True
|
|
33
|
+
self.channel_joined = True
|
|
34
|
+
|
|
35
|
+
async def send_message(self, event, payload):
|
|
36
|
+
self.messages.append((event, payload))
|
|
37
|
+
|
|
38
|
+
async def disconnect(self):
|
|
39
|
+
self.connected = False
|
|
40
|
+
self.channel_joined = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DispatcherSessionTest(unittest.IsolatedAsyncioTestCase):
|
|
44
|
+
async def test_slow_tool_does_not_block_other_calls_and_duplicate_is_idempotent(self):
|
|
45
|
+
agent = DemoAgent(api_key="test-key")
|
|
46
|
+
agent.connection = FakeConnection()
|
|
47
|
+
await agent.start(conversation_id="conversation-123")
|
|
48
|
+
|
|
49
|
+
await agent._handle_platform_message(
|
|
50
|
+
"tool_call_request",
|
|
51
|
+
{"call_id": "slow", "tool_name": "stall", "arguments": {}, "timeout_seconds": 0.1},
|
|
52
|
+
)
|
|
53
|
+
await agent._handle_platform_message(
|
|
54
|
+
"tool_call_request",
|
|
55
|
+
{"call_id": "fast", "tool_name": "sample", "arguments": {}},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
async def wait_for_messages(count):
|
|
59
|
+
while len([event for event, _ in agent.connection.messages if event.startswith("tool_call_")]) < count:
|
|
60
|
+
await asyncio.sleep(0.01)
|
|
61
|
+
|
|
62
|
+
await asyncio.wait_for(wait_for_messages(2), timeout=2)
|
|
63
|
+
calls = [(event, payload) for event, payload in agent.connection.messages if event.startswith("tool_call_")]
|
|
64
|
+
self.assertEqual(calls[0][0], "tool_call_response")
|
|
65
|
+
self.assertEqual(calls[0][1]["call_id"], "fast")
|
|
66
|
+
self.assertEqual(calls[1][0], "tool_call_error")
|
|
67
|
+
self.assertEqual(calls[1][1]["error"]["message"], "Tool execution timed out")
|
|
68
|
+
|
|
69
|
+
await agent._handle_platform_message(
|
|
70
|
+
"tool_call_request",
|
|
71
|
+
{"call_id": "fast", "tool_name": "sample", "arguments": {}},
|
|
72
|
+
)
|
|
73
|
+
await asyncio.wait_for(wait_for_messages(3), timeout=2)
|
|
74
|
+
self.assertEqual(agent.sample_calls, 1)
|
|
75
|
+
self.assertEqual(calls[0][1], agent.connection.messages[-1][1])
|
|
76
|
+
await agent.stop()
|
|
77
|
+
|
|
78
|
+
async def test_registers_tools_for_dispatcher_session_before_lifecycle_event(self):
|
|
79
|
+
agent = DemoAgent(api_key="test-key")
|
|
80
|
+
agent.connection = FakeConnection()
|
|
81
|
+
|
|
82
|
+
await agent.start(conversation_id="conversation-123")
|
|
83
|
+
await agent._handle_conversation_lifecycle(
|
|
84
|
+
{"event": "conversation_started", "conversation_id": "conversation-123"}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
registrations = [
|
|
88
|
+
payload
|
|
89
|
+
for event, payload in agent.connection.messages
|
|
90
|
+
if event == "register_conversation_tools"
|
|
91
|
+
]
|
|
92
|
+
self.assertEqual(len(registrations), 1)
|
|
93
|
+
self.assertEqual(registrations[0]["conversation_id"], "conversation-123")
|
|
94
|
+
self.assertEqual(registrations[0]["tools"][0]["name"], "sample")
|
|
95
|
+
|
|
96
|
+
await agent._send_tool_result("call-123", {"answer": "ok"})
|
|
97
|
+
self.assertEqual(
|
|
98
|
+
agent.connection.messages[-1],
|
|
99
|
+
(
|
|
100
|
+
"tool_call_response",
|
|
101
|
+
{
|
|
102
|
+
"call_id": "call-123",
|
|
103
|
+
"conversation_id": "conversation-123",
|
|
104
|
+
"result": {"answer": "ok"},
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
await agent._handle_tool_call_request(
|
|
110
|
+
{"call_id": "call-456", "tool_name": "sample", "arguments": {}}
|
|
111
|
+
)
|
|
112
|
+
self.assertEqual(agent.connection.messages[-1][1]["tool_name"], "sample")
|
|
113
|
+
|
|
114
|
+
ended = []
|
|
115
|
+
agent.on_conversation_ended(ended.append)
|
|
116
|
+
await agent._handle_conversation_lifecycle(
|
|
117
|
+
{"event": "conversation_ended", "conversation_id": "conversation-123"}
|
|
118
|
+
)
|
|
119
|
+
await agent.stop()
|
|
120
|
+
self.assertEqual(ended, ["conversation-123"])
|
conversimple_sdk-0.2.3/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
The MIT License (MIT)
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025-present, Syed Aqueel Haider and ConverSimple
|
|
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
|
|
13
|
-
all 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
|
|
21
|
-
THE SOFTWARE.
|
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
"""Tests for Conversimple Platform API Client."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from datetime import datetime
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
import responses
|
|
8
|
-
|
|
9
|
-
from conversimple import (
|
|
10
|
-
PlatformClient,
|
|
11
|
-
Agent,
|
|
12
|
-
Deployment,
|
|
13
|
-
ApiKeyInfo,
|
|
14
|
-
ApiKeyUsage,
|
|
15
|
-
APIError,
|
|
16
|
-
ValidationError,
|
|
17
|
-
NotFoundError,
|
|
18
|
-
UnauthorizedError,
|
|
19
|
-
ForbiddenError,
|
|
20
|
-
)
|
|
21
|
-
from conversimple.config import Config
|
|
22
|
-
|
|
23
|
-
# Use Config defaults for all test URLs
|
|
24
|
-
BASE_URL = Config.API_ENDPOINT
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class TestPlatformClientInitialization:
|
|
28
|
-
"""Test PlatformClient initialization."""
|
|
29
|
-
|
|
30
|
-
def test_init_with_defaults(self):
|
|
31
|
-
"""Test initialization with default parameters."""
|
|
32
|
-
client = PlatformClient(api_key="test-key")
|
|
33
|
-
assert client._http_client.api_key == "test-key"
|
|
34
|
-
assert client._http_client.api_endpoint == Config.API_ENDPOINT
|
|
35
|
-
assert client._http_client.timeout == 30
|
|
36
|
-
|
|
37
|
-
def test_init_with_custom_endpoint(self):
|
|
38
|
-
"""Test initialization with custom endpoint."""
|
|
39
|
-
client = PlatformClient(
|
|
40
|
-
api_key="test-key",
|
|
41
|
-
api_endpoint="https://api.dev.conversimple.com"
|
|
42
|
-
)
|
|
43
|
-
assert client._http_client.api_endpoint == "https://api.dev.conversimple.com"
|
|
44
|
-
|
|
45
|
-
def test_init_with_trailing_slash(self):
|
|
46
|
-
"""Test that trailing slash is stripped from endpoint."""
|
|
47
|
-
client = PlatformClient(
|
|
48
|
-
api_key="test-key",
|
|
49
|
-
api_endpoint=f"{Config.API_ENDPOINT}/"
|
|
50
|
-
)
|
|
51
|
-
assert client._http_client.api_endpoint == Config.API_ENDPOINT
|
|
52
|
-
|
|
53
|
-
def test_endpoints_are_accessible(self):
|
|
54
|
-
"""Test that all endpoints are accessible."""
|
|
55
|
-
client = PlatformClient(api_key="test-key")
|
|
56
|
-
assert hasattr(client, "agents")
|
|
57
|
-
assert hasattr(client, "deployments")
|
|
58
|
-
assert hasattr(client, "api_keys")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class TestHTTPClientHeaders:
|
|
62
|
-
"""Test HTTP client header management."""
|
|
63
|
-
|
|
64
|
-
def test_auth_header_format(self):
|
|
65
|
-
"""Test that auth header is properly formatted."""
|
|
66
|
-
client = PlatformClient(api_key="my-secret-key")
|
|
67
|
-
headers = client._http_client._get_headers()
|
|
68
|
-
assert headers["Authorization"] == "Bearer my-secret-key"
|
|
69
|
-
|
|
70
|
-
def test_content_type_header(self):
|
|
71
|
-
"""Test content type header is set."""
|
|
72
|
-
client = PlatformClient(api_key="test-key")
|
|
73
|
-
headers = client._http_client._get_headers()
|
|
74
|
-
assert headers["Content-Type"] == "application/json"
|
|
75
|
-
|
|
76
|
-
def test_user_agent_header(self):
|
|
77
|
-
"""Test user agent header includes version."""
|
|
78
|
-
client = PlatformClient(api_key="test-key")
|
|
79
|
-
headers = client._http_client._get_headers()
|
|
80
|
-
assert "conversimple-sdk" in headers["User-Agent"]
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
class TestExceptionHandling:
|
|
84
|
-
"""Test exception handling for different HTTP status codes."""
|
|
85
|
-
|
|
86
|
-
@responses.activate
|
|
87
|
-
def test_422_validation_error(self):
|
|
88
|
-
"""Test that 422 status raises ValidationError."""
|
|
89
|
-
responses.add(
|
|
90
|
-
responses.GET,
|
|
91
|
-
f"{BASE_URL}/api/v1/agents",
|
|
92
|
-
json={
|
|
93
|
-
"message": "Validation failed",
|
|
94
|
-
"errors": {"name": "Name is required"}
|
|
95
|
-
},
|
|
96
|
-
status=422,
|
|
97
|
-
)
|
|
98
|
-
|
|
99
|
-
client = PlatformClient(api_key="test-key")
|
|
100
|
-
|
|
101
|
-
with pytest.raises(ValidationError) as exc_info:
|
|
102
|
-
client._http_client.get("/api/v1/agents")
|
|
103
|
-
|
|
104
|
-
assert exc_info.value.status_code == 422
|
|
105
|
-
assert exc_info.value.errors == {"name": "Name is required"}
|
|
106
|
-
|
|
107
|
-
@responses.activate
|
|
108
|
-
def test_404_not_found_error(self):
|
|
109
|
-
"""Test that 404 status raises NotFoundError."""
|
|
110
|
-
responses.add(
|
|
111
|
-
responses.GET,
|
|
112
|
-
f"{BASE_URL}/api/v1/agents/123",
|
|
113
|
-
json={"message": "Agent not found"},
|
|
114
|
-
status=404,
|
|
115
|
-
)
|
|
116
|
-
|
|
117
|
-
client = PlatformClient(api_key="test-key")
|
|
118
|
-
|
|
119
|
-
with pytest.raises(NotFoundError) as exc_info:
|
|
120
|
-
client._http_client.get("/api/v1/agents/123")
|
|
121
|
-
|
|
122
|
-
assert exc_info.value.status_code == 404
|
|
123
|
-
|
|
124
|
-
@responses.activate
|
|
125
|
-
def test_401_unauthorized_error(self):
|
|
126
|
-
"""Test that 401 status raises UnauthorizedError."""
|
|
127
|
-
responses.add(
|
|
128
|
-
responses.GET,
|
|
129
|
-
f"{BASE_URL}/api/v1/agents",
|
|
130
|
-
json={"message": "Invalid API key"},
|
|
131
|
-
status=401,
|
|
132
|
-
)
|
|
133
|
-
|
|
134
|
-
client = PlatformClient(api_key="invalid-key")
|
|
135
|
-
|
|
136
|
-
with pytest.raises(UnauthorizedError):
|
|
137
|
-
client._http_client.get("/api/v1/agents")
|
|
138
|
-
|
|
139
|
-
@responses.activate
|
|
140
|
-
def test_403_forbidden_error(self):
|
|
141
|
-
"""Test that 403 status raises ForbiddenError."""
|
|
142
|
-
responses.add(
|
|
143
|
-
responses.GET,
|
|
144
|
-
f"{BASE_URL}/api/v1/agents",
|
|
145
|
-
json={"message": "Forbidden"},
|
|
146
|
-
status=403,
|
|
147
|
-
)
|
|
148
|
-
|
|
149
|
-
client = PlatformClient(api_key="test-key")
|
|
150
|
-
|
|
151
|
-
with pytest.raises(ForbiddenError):
|
|
152
|
-
client._http_client.get("/api/v1/agents")
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
class TestAgentEndpoint:
|
|
156
|
-
"""Test Agent management endpoints."""
|
|
157
|
-
|
|
158
|
-
@responses.activate
|
|
159
|
-
def test_list_agents(self):
|
|
160
|
-
"""Test listing agents."""
|
|
161
|
-
responses.add(
|
|
162
|
-
responses.GET,
|
|
163
|
-
f"{BASE_URL}/api/v1/agents",
|
|
164
|
-
json={
|
|
165
|
-
"success": True,
|
|
166
|
-
"data": [
|
|
167
|
-
{
|
|
168
|
-
"id": "agent-1",
|
|
169
|
-
"name": "Support Bot",
|
|
170
|
-
"description": "Support agent",
|
|
171
|
-
"status": "published",
|
|
172
|
-
"version": 1,
|
|
173
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
174
|
-
"updated_at": "2024-01-01T00:00:00Z",
|
|
175
|
-
}
|
|
176
|
-
],
|
|
177
|
-
"meta": {"page": 1, "per_page": 20, "total_count": 1}
|
|
178
|
-
},
|
|
179
|
-
status=200,
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
client = PlatformClient(api_key="test-key")
|
|
183
|
-
agents, meta = client.agents.list_agents(page=1, per_page=20)
|
|
184
|
-
|
|
185
|
-
assert len(agents) == 1
|
|
186
|
-
assert agents[0].name == "Support Bot"
|
|
187
|
-
assert meta["total_count"] == 1
|
|
188
|
-
|
|
189
|
-
@responses.activate
|
|
190
|
-
def test_create_agent(self):
|
|
191
|
-
"""Test creating an agent."""
|
|
192
|
-
responses.add(
|
|
193
|
-
responses.POST,
|
|
194
|
-
f"{BASE_URL}/api/v1/agents",
|
|
195
|
-
json={
|
|
196
|
-
"success": True,
|
|
197
|
-
"data": {
|
|
198
|
-
"id": "agent-new",
|
|
199
|
-
"name": "New Agent",
|
|
200
|
-
"description": "A new agent",
|
|
201
|
-
"status": "draft",
|
|
202
|
-
"version": 1,
|
|
203
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
204
|
-
"updated_at": "2024-01-01T00:00:00Z",
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
status=200,
|
|
208
|
-
)
|
|
209
|
-
|
|
210
|
-
client = PlatformClient(api_key="test-key")
|
|
211
|
-
agent = client.agents.create_agent(
|
|
212
|
-
name="New Agent",
|
|
213
|
-
description="A new agent"
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
assert agent.id == "agent-new"
|
|
217
|
-
assert agent.name == "New Agent"
|
|
218
|
-
assert agent.status == "draft"
|
|
219
|
-
|
|
220
|
-
@responses.activate
|
|
221
|
-
def test_get_agent(self):
|
|
222
|
-
"""Test getting a single agent."""
|
|
223
|
-
responses.add(
|
|
224
|
-
responses.GET,
|
|
225
|
-
f"{BASE_URL}/api/v1/agents/agent-1",
|
|
226
|
-
json={
|
|
227
|
-
"success": True,
|
|
228
|
-
"data": {
|
|
229
|
-
"id": "agent-1",
|
|
230
|
-
"name": "Support Bot",
|
|
231
|
-
"description": "Support agent",
|
|
232
|
-
"status": "published",
|
|
233
|
-
"version": 1,
|
|
234
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
235
|
-
"updated_at": "2024-01-01T00:00:00Z",
|
|
236
|
-
}
|
|
237
|
-
},
|
|
238
|
-
status=200,
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
client = PlatformClient(api_key="test-key")
|
|
242
|
-
agent = client.agents.get_agent("agent-1")
|
|
243
|
-
|
|
244
|
-
assert agent.id == "agent-1"
|
|
245
|
-
assert agent.name == "Support Bot"
|
|
246
|
-
|
|
247
|
-
@responses.activate
|
|
248
|
-
def test_update_agent(self):
|
|
249
|
-
"""Test updating an agent."""
|
|
250
|
-
responses.add(
|
|
251
|
-
responses.PUT,
|
|
252
|
-
f"{BASE_URL}/api/v1/agents/agent-1",
|
|
253
|
-
json={
|
|
254
|
-
"success": True,
|
|
255
|
-
"data": {
|
|
256
|
-
"id": "agent-1",
|
|
257
|
-
"name": "Updated Bot",
|
|
258
|
-
"description": "Support agent",
|
|
259
|
-
"status": "draft",
|
|
260
|
-
"version": 2,
|
|
261
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
262
|
-
"updated_at": "2024-01-02T00:00:00Z",
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
|
-
status=200,
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
client = PlatformClient(api_key="test-key")
|
|
269
|
-
agent = client.agents.update_agent("agent-1", name="Updated Bot")
|
|
270
|
-
|
|
271
|
-
assert agent.name == "Updated Bot"
|
|
272
|
-
assert agent.version == 2
|
|
273
|
-
|
|
274
|
-
@responses.activate
|
|
275
|
-
def test_delete_agent(self):
|
|
276
|
-
"""Test deleting an agent."""
|
|
277
|
-
responses.add(
|
|
278
|
-
responses.DELETE,
|
|
279
|
-
f"{BASE_URL}/api/v1/agents/agent-1",
|
|
280
|
-
json={"success": True},
|
|
281
|
-
status=200,
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
client = PlatformClient(api_key="test-key")
|
|
285
|
-
client.agents.delete_agent("agent-1")
|
|
286
|
-
|
|
287
|
-
assert len(responses.calls) == 1
|
|
288
|
-
|
|
289
|
-
@responses.activate
|
|
290
|
-
def test_publish_agent(self):
|
|
291
|
-
"""Test publishing an agent."""
|
|
292
|
-
responses.add(
|
|
293
|
-
responses.POST,
|
|
294
|
-
f"{BASE_URL}/api/v1/agents/agent-1/publish",
|
|
295
|
-
json={
|
|
296
|
-
"success": True,
|
|
297
|
-
"data": {
|
|
298
|
-
"id": "agent-1",
|
|
299
|
-
"name": "Support Bot",
|
|
300
|
-
"description": "Support agent",
|
|
301
|
-
"status": "published",
|
|
302
|
-
"version": 2,
|
|
303
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
304
|
-
"updated_at": "2024-01-02T00:00:00Z",
|
|
305
|
-
}
|
|
306
|
-
},
|
|
307
|
-
status=200,
|
|
308
|
-
)
|
|
309
|
-
|
|
310
|
-
client = PlatformClient(api_key="test-key")
|
|
311
|
-
agent = client.agents.publish_agent("agent-1")
|
|
312
|
-
|
|
313
|
-
assert agent.status == "published"
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
class TestDeploymentEndpoint:
|
|
317
|
-
"""Test Deployment management endpoints."""
|
|
318
|
-
|
|
319
|
-
@responses.activate
|
|
320
|
-
def test_list_deployments(self):
|
|
321
|
-
"""Test listing deployments."""
|
|
322
|
-
responses.add(
|
|
323
|
-
responses.GET,
|
|
324
|
-
f"{BASE_URL}/api/v1/deployments",
|
|
325
|
-
json={
|
|
326
|
-
"success": True,
|
|
327
|
-
"data": [
|
|
328
|
-
{
|
|
329
|
-
"id": "deploy-1",
|
|
330
|
-
"name": "Support Widget",
|
|
331
|
-
"agent_id": "agent-1",
|
|
332
|
-
"channel": "widget",
|
|
333
|
-
"status": "active",
|
|
334
|
-
"environment": "widget",
|
|
335
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
336
|
-
"updated_at": "2024-01-01T00:00:00Z",
|
|
337
|
-
}
|
|
338
|
-
],
|
|
339
|
-
"meta": {"page": 1, "per_page": 20, "total_count": 1}
|
|
340
|
-
},
|
|
341
|
-
status=200,
|
|
342
|
-
)
|
|
343
|
-
|
|
344
|
-
client = PlatformClient(api_key="test-key")
|
|
345
|
-
deployments, meta = client.deployments.list_deployments()
|
|
346
|
-
|
|
347
|
-
assert len(deployments) == 1
|
|
348
|
-
assert deployments[0].name == "Support Widget"
|
|
349
|
-
|
|
350
|
-
@responses.activate
|
|
351
|
-
def test_create_deployment(self):
|
|
352
|
-
"""Test creating a deployment."""
|
|
353
|
-
responses.add(
|
|
354
|
-
responses.POST,
|
|
355
|
-
f"{BASE_URL}/api/v1/deployments",
|
|
356
|
-
json={
|
|
357
|
-
"success": True,
|
|
358
|
-
"data": {
|
|
359
|
-
"id": "deploy-new",
|
|
360
|
-
"name": "Support Widget",
|
|
361
|
-
"agent_id": "agent-1",
|
|
362
|
-
"channel": "widget",
|
|
363
|
-
"status": "pending",
|
|
364
|
-
"environment": "widget",
|
|
365
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
366
|
-
"updated_at": "2024-01-01T00:00:00Z",
|
|
367
|
-
}
|
|
368
|
-
},
|
|
369
|
-
status=200,
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
client = PlatformClient(api_key="test-key")
|
|
373
|
-
deployment = client.deployments.create_deployment(
|
|
374
|
-
name="Support Widget",
|
|
375
|
-
agent_id="agent-1",
|
|
376
|
-
channel="widget"
|
|
377
|
-
)
|
|
378
|
-
|
|
379
|
-
assert deployment.id == "deploy-new"
|
|
380
|
-
assert deployment.channel == "widget"
|
|
381
|
-
|
|
382
|
-
@responses.activate
|
|
383
|
-
def test_activate_deployment(self):
|
|
384
|
-
"""Test activating a deployment."""
|
|
385
|
-
responses.add(
|
|
386
|
-
responses.POST,
|
|
387
|
-
f"{BASE_URL}/api/v1/deployments/deploy-1/activate",
|
|
388
|
-
json={
|
|
389
|
-
"success": True,
|
|
390
|
-
"data": {
|
|
391
|
-
"id": "deploy-1",
|
|
392
|
-
"name": "Support Widget",
|
|
393
|
-
"agent_id": "agent-1",
|
|
394
|
-
"channel": "widget",
|
|
395
|
-
"status": "active",
|
|
396
|
-
"environment": "widget",
|
|
397
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
398
|
-
"updated_at": "2024-01-02T00:00:00Z",
|
|
399
|
-
}
|
|
400
|
-
},
|
|
401
|
-
status=200,
|
|
402
|
-
)
|
|
403
|
-
|
|
404
|
-
client = PlatformClient(api_key="test-key")
|
|
405
|
-
deployment = client.deployments.activate_deployment("deploy-1")
|
|
406
|
-
|
|
407
|
-
assert deployment.status == "active"
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
class TestApiKeyEndpoint:
|
|
411
|
-
"""Test API Key management endpoints."""
|
|
412
|
-
|
|
413
|
-
@responses.activate
|
|
414
|
-
def test_get_api_key_info(self):
|
|
415
|
-
"""Test getting API key info."""
|
|
416
|
-
responses.add(
|
|
417
|
-
responses.GET,
|
|
418
|
-
f"{BASE_URL}/api/v1/settings/api-key",
|
|
419
|
-
json={
|
|
420
|
-
"success": True,
|
|
421
|
-
"data": {
|
|
422
|
-
"status": "active",
|
|
423
|
-
"last_4": "1234",
|
|
424
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
425
|
-
"last_used_at": "2024-01-02T00:00:00Z",
|
|
426
|
-
}
|
|
427
|
-
},
|
|
428
|
-
status=200,
|
|
429
|
-
)
|
|
430
|
-
|
|
431
|
-
client = PlatformClient(api_key="test-key")
|
|
432
|
-
key_info = client.api_keys.get_api_key_info()
|
|
433
|
-
|
|
434
|
-
assert key_info.status == "active"
|
|
435
|
-
assert key_info.last_4_chars == "1234"
|
|
436
|
-
|
|
437
|
-
@responses.activate
|
|
438
|
-
def test_get_api_key_usage(self):
|
|
439
|
-
"""Test getting API key usage statistics."""
|
|
440
|
-
responses.add(
|
|
441
|
-
responses.GET,
|
|
442
|
-
f"{BASE_URL}/api/v1/settings/api-key/usage",
|
|
443
|
-
json={
|
|
444
|
-
"success": True,
|
|
445
|
-
"data": {
|
|
446
|
-
"requests_24h": 100,
|
|
447
|
-
"requests_month": 5000,
|
|
448
|
-
"rate_limit": 10000,
|
|
449
|
-
"rate_limit_remaining": 5000,
|
|
450
|
-
"last_request_at": "2024-01-02T12:00:00Z",
|
|
451
|
-
}
|
|
452
|
-
},
|
|
453
|
-
status=200,
|
|
454
|
-
)
|
|
455
|
-
|
|
456
|
-
client = PlatformClient(api_key="test-key")
|
|
457
|
-
usage = client.api_keys.get_api_key_usage()
|
|
458
|
-
|
|
459
|
-
assert usage.requests_24h == 100
|
|
460
|
-
assert usage.rate_limit == 10000
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|