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.
Files changed (30) hide show
  1. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/PKG-INFO +15 -10
  2. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/README.md +13 -4
  3. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/agent.py +102 -11
  4. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/config.py +1 -1
  5. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/connection.py +34 -17
  6. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/dispatcher.py +22 -0
  7. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/tools.py +2 -2
  8. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/PKG-INFO +15 -10
  9. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/SOURCES.txt +2 -2
  10. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/entry_points.txt +1 -0
  11. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/setup.py +3 -4
  12. conversimple_sdk-0.2.4/tests/test_dispatcher_session.py +120 -0
  13. conversimple_sdk-0.2.3/LICENSE +0 -21
  14. conversimple_sdk-0.2.3/tests/test_platform_api_client.py +0 -460
  15. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/__init__.py +0 -0
  16. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/__init__.py +0 -0
  17. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/client.py +0 -0
  18. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/__init__.py +0 -0
  19. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/agents.py +0 -0
  20. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/api_keys.py +0 -0
  21. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/endpoints/deployments.py +0 -0
  22. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/exceptions.py +0 -0
  23. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/api/models.py +0 -0
  24. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/callbacks.py +0 -0
  25. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple/utils.py +0 -0
  26. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/dependency_links.txt +0 -0
  27. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/not-zip-safe +0 -0
  28. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/requires.txt +0 -0
  29. {conversimple_sdk-0.2.3 → conversimple_sdk-0.2.4}/conversimple_sdk.egg-info/top_level.txt +0 -0
  30. {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
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.8
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
- python -m conversimple.dispatcher \
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
- python -m conversimple.dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
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://api.conversimple.com/sdk/websocket"
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
- python -m conversimple.dispatcher \
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
- python -m conversimple.dispatcher \
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
- python -m conversimple.dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
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://api.conversimple.com/sdk/websocket"
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
- python -m conversimple.dispatcher \
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
- # Store tools for later registration when real conversations start
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.callback_manager.trigger_conversation_ended(self.conversation_id)
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=payload.get("timeout_seconds", 30)
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 self.tool_registry.execute_tool(tool_name, arguments)
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
- await self._send_tool_result(call_id, result)
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
- await self._send_tool_error(call_id, str(e))
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.callback_manager.trigger_conversation_ended(conversation_id, payload)
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://api.conversimple.com/sdk/websocket"
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
- await self._handle_connection_error(e)
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.cancel()
253
- try:
254
- await self.heartbeat_task
255
- except asyncio.CancelledError:
256
- pass
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.cancel()
260
- try:
261
- await self.message_task
262
- except asyncio.CancelledError:
263
- pass
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.info("WebSocket connection closed")
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(**arguments)
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
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.8
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
- python -m conversimple.dispatcher \
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
- python -m conversimple.dispatcher --api-key "$CONVERSIMPLE_API_KEY" \
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://api.conversimple.com/sdk/websocket"
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
- python -m conversimple.dispatcher \
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/test_platform_api_client.py
27
+ tests/test_dispatcher_session.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
+ conversimple-dispatcher = conversimple.dispatcher:main
2
3
  conversimple-example = examples.simple_agent:main
@@ -16,7 +16,7 @@ except FileNotFoundError:
16
16
 
17
17
  setup(
18
18
  name="conversimple-sdk",
19
- version="0.2.3", # Enhanced added API client
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.8",
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"])
@@ -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