attp-client 0.0.8__tar.gz → 0.0.10__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 (37) hide show
  1. {attp_client-0.0.8 → attp_client-0.0.10}/PKG-INFO +2 -2
  2. {attp_client-0.0.8 → attp_client-0.0.10}/pyproject.toml +2 -2
  3. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/catalog.py +9 -2
  4. attp_client-0.0.10/src/attp_client/client.py +207 -0
  5. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/inference.py +73 -0
  6. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/session.py +11 -2
  7. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/tools.py +1 -1
  8. attp_client-0.0.10/src/attp_client/utils/envelopizer.py +9 -0
  9. attp_client-0.0.8/src/attp_client/client.py +0 -119
  10. {attp_client-0.0.8 → attp_client-0.0.10}/README.md +0 -0
  11. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/__init__.py +0 -0
  12. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/consts.py +0 -0
  13. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/attp_exception.py +0 -0
  14. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/correlated_rpc_exception.py +0 -0
  15. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/dead_session.py +0 -0
  16. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/not_found.py +0 -0
  17. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/serialization_error.py +0 -0
  18. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/errors/unauthenticated_error.py +0 -0
  19. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/catalogs/catalog.py +0 -0
  20. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/catalogs/tools/envelope.py +0 -0
  21. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/error.py +0 -0
  22. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/handshake/auth.py +0 -0
  23. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/handshake/hello.py +0 -0
  24. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/handshake/ready.py +0 -0
  25. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/inference/enums/message_data_type.py +0 -0
  26. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/inference/enums/message_emergency_type.py +0 -0
  27. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/inference/enums/message_type.py +0 -0
  28. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/inference/message.py +0 -0
  29. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/inference/tool.py +0 -0
  30. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/interfaces/route_mappings.py +0 -0
  31. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/misc/fixed_basemodel.py +0 -0
  32. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/misc/serializable.py +0 -0
  33. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/router.py +0 -0
  34. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/types/route_mapping.py +0 -0
  35. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/utils/context_awaiter.py +0 -0
  36. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/utils/route_mapper.py +0 -0
  37. {attp_client-0.0.8 → attp_client-0.0.10}/src/attp_client/utils/serializer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: attp-client
3
- Version: 0.0.8
3
+ Version: 0.0.10
4
4
  Summary: A python-sdk client for interacting with AgentHub's ATTP protocol (Agent Tool Transport Protocol)
5
5
  License: MIT
6
6
  Author: Ascender Team
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3.11
11
11
  Classifier: Programming Language :: Python :: 3.12
12
12
  Classifier: Programming Language :: Python :: 3.13
13
13
  Requires-Dist: ascender-framework (>=2.0rc7,<3.0)
14
- Requires-Dist: attp-core (==0.1.10)
14
+ Requires-Dist: attp-core (==0.1.13)
15
15
  Requires-Dist: msgpack (>=1.1.1,<2.0.0)
16
16
  Requires-Dist: pydantic (>=2.11.7,<3.0.0)
17
17
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "attp-client"
3
- version = "0.0.8"
3
+ version = "0.0.10"
4
4
  description = "A python-sdk client for interacting with AgentHub's ATTP protocol (Agent Tool Transport Protocol)"
5
5
  authors = [
6
6
  {name = "Ascender Team"}
@@ -10,7 +10,7 @@ readme = "README.md"
10
10
  requires-python = ">=3.11,<3.14"
11
11
  dependencies = [
12
12
  "pydantic (>=2.11.7,<3.0.0)",
13
- "attp-core (==0.1.10)",
13
+ "attp-core (==0.1.13)",
14
14
  "msgpack (>=1.1.1,<2.0.0)",
15
15
  "ascender-framework (>=2.0rc7,<3.0)"
16
16
  ]
@@ -28,6 +28,7 @@ class AttpCatalog:
28
28
  self.tool_manager = manager
29
29
  self.attached_tools = {}
30
30
  self.tool_name_to_id_symlink = {}
31
+ self.disposable = None
31
32
 
32
33
  self.responder = self.tool_manager.router.responder
33
34
 
@@ -59,14 +60,20 @@ class AttpCatalog:
59
60
  from reactivex import empty
60
61
  return empty()
61
62
 
62
- self.responder.pipe(
63
- ops.filter(lambda item: item.payload is not None and item.correlation_id == 1),
63
+ self.disposable = self.responder.pipe(
64
+ ops.filter(lambda item: item.payload is not None and item.route_id == 2),
64
65
  ops.map(lambda item: envelopize(item)),
65
66
  ops.catch(catch_handler),
66
67
  ops.filter(lambda item: item.catalog == self.catalog_name and item.tool_id in self.attached_tools),
67
68
  ops.observe_on(scheduler),
68
69
  ).subscribe(lambda item: handle_call(item))
69
70
 
71
+ async def handle_callback(self, envelope: IEnvelope) -> Any:
72
+ if envelope.tool_id not in self.attached_tools:
73
+ raise NotFoundError(f"Tool {envelope.tool_id} not marked as registered and wasn't found in the catalog {self.catalog_name}.")
74
+
75
+ return await self.handle_call(envelope)
76
+
70
77
  async def attach_tool(
71
78
  self,
72
79
  callback: Callable[[IEnvelope], Any],
@@ -0,0 +1,207 @@
1
+ import asyncio
2
+ from functools import cached_property
3
+ from logging import Logger, getLogger
4
+ from typing import Any, Callable
5
+ from attp_core.rs_api import AttpClientSession, Limits
6
+ from reactivex import Subject, operators as ops
7
+ from reactivex.scheduler.eventloop import AsyncIOScheduler
8
+ from attp_core.rs_api import PyAttpMessage
9
+
10
+ from attp_client.catalog import AttpCatalog
11
+ from attp_client.errors.dead_session import DeadSessionError
12
+ from attp_client.inference import AttpInferenceAPI
13
+ from attp_client.interfaces.catalogs.catalog import ICatalogResponse
14
+ from attp_client.interfaces.error import IErr
15
+ from attp_client.misc.serializable import Serializable
16
+ from attp_client.router import AttpRouter
17
+ from attp_client.session import SessionDriver
18
+ from attp_client.tools import ToolsManager
19
+ from attp_client.types.route_mapping import AttpRouteMapping, RouteType
20
+ from attp_client.utils import envelopizer
21
+
22
+ from attp_core.rs_api import AttpCommand
23
+
24
+ class ATTPClient:
25
+
26
+ is_connected: bool
27
+ client: AttpClientSession
28
+ session: SessionDriver | None
29
+ routes: list[AttpRouteMapping]
30
+ inference: AttpInferenceAPI
31
+ catalogs: list[AttpCatalog]
32
+
33
+ def __init__(
34
+ self,
35
+ agt_token: str,
36
+ organization_id: int,
37
+ *,
38
+ connection_url: str | None = None,
39
+ max_retries: int = 20,
40
+ limits: Limits | None = None,
41
+ logger: Logger | None = None
42
+ ):
43
+ self.__agt_token = agt_token
44
+ self.organization_id = organization_id
45
+ self.connection_url = connection_url or "attp://localhost:6563"
46
+
47
+ self.session = None
48
+ self.max_retries = max_retries
49
+ self.limits = limits or Limits(max_payload_size=50000)
50
+ self.client = AttpClientSession(self.connection_url, limits=self.limits)
51
+ self.logger = logger
52
+
53
+ self.route_increment_index = 2
54
+
55
+ self.responder = Subject[PyAttpMessage]()
56
+ self.routes = []
57
+ self.catalogs = []
58
+ self.disposable = None
59
+
60
+ async def connect(self):
61
+ # Open the connection
62
+ client = await self.client.connect(self.max_retries)
63
+
64
+ if not client.session:
65
+ raise ConnectionError("Failed to connect to ATTP server after 10 attempts!")
66
+
67
+ self.session = SessionDriver(
68
+ client.session,
69
+ agt_token=self.__agt_token,
70
+ organization_id=self.organization_id,
71
+ # route_mappings=self.routes,
72
+ logger=self.logger or getLogger("Ascender Framework")
73
+ )
74
+ asyncio.create_task(self.session.start_listener())
75
+ # Send an authentication frame as soon as connection estabilishes with agenthub
76
+ await self.session.authenticate(self.routes)
77
+ asyncio.create_task(self.session.listen(self.responder))
78
+
79
+ self.router = AttpRouter(self.responder, self.session)
80
+ self.inference = AttpInferenceAPI(self.router)
81
+
82
+ self.add_event_handler("tools:call", "message", self._tool_callback)
83
+
84
+
85
+
86
+ self.disposable = self.responder.pipe(
87
+ ops.subscribe_on(AsyncIOScheduler(asyncio.get_event_loop())),
88
+ ).subscribe(
89
+ on_next=lambda item: self.logger.debug(f"Received message on route {item.route_id} with correlation ID {item.correlation_id}"),
90
+ on_error=lambda e: self.logger.error(f"Error in responder stream: {e}"),
91
+ )
92
+
93
+ async def close(self):
94
+ if self.session:
95
+ await self.session.close()
96
+ self.session = None
97
+ self.is_connected = False
98
+
99
+ @cached_property
100
+ def tools(self):
101
+ return ToolsManager(self.router)
102
+
103
+ async def catalog(self, catalog_name: str):
104
+ if any(c.catalog_name == catalog_name for c in self.catalogs):
105
+ return next(c for c in self.catalogs if c.catalog_name == catalog_name)
106
+
107
+ catalog = await self.router.send(
108
+ "tools:catalogs:specific",
109
+ Serializable[dict[str, str]]({"catalog_name": catalog_name}),
110
+ timeout=10,
111
+ expected_response=ICatalogResponse
112
+ )
113
+ self.catalogs.append(
114
+ AttpCatalog(id=catalog.catalog_id, catalog_name=catalog_name, manager=self.tools)
115
+ )
116
+
117
+ await self.catalogs[-1].start_tool_listener()
118
+
119
+ return self.catalogs[-1] # Return the newly added catalog
120
+
121
+ async def close_catalog(self, catalog: AttpCatalog):
122
+ await catalog.detach_all_tools()
123
+ self.catalogs.remove(catalog)
124
+
125
+ async def _tool_callback(self, message: PyAttpMessage):
126
+ if not self.session:
127
+ raise DeadSessionError(self.organization_id)
128
+
129
+ if not message.correlation_id:
130
+ await self.session.send_error(IErr(
131
+ detail={"message": "Correlation ID was missing in the message.", "code": "MissingCorrelationId"},
132
+ ))
133
+ return
134
+
135
+ print("TOOL CALLBACK MESSAGE:", message.payload)
136
+ try:
137
+ envelope = envelopizer.envelopize(message)
138
+ except ValueError as e:
139
+ await self.session.send_error(IErr(
140
+ detail={"message": str(e), "code": "InvalidPayload"},
141
+ ))
142
+ return
143
+
144
+ catalog = next((c for c in self.catalogs if c.id == envelope.tool_id), None)
145
+ if not catalog:
146
+ await self.session.send_error(IErr(
147
+ detail={"message": f"Catalog with id {envelope.tool_id} not found.", "code": "NotFoundError"},
148
+ ))
149
+ return
150
+
151
+ response = await catalog.handle_callback(envelope)
152
+
153
+ await self.session.respond(route=message.route_id, correlation_id=message.correlation_id, payload=response)
154
+
155
+ async def _handle_incoming(self, message: PyAttpMessage):
156
+ relevant_route = next((route for route in self.routes if route.route_id == message.route_id), None)
157
+
158
+ if not relevant_route:
159
+ if self.logger:
160
+ self.logger.warning(f"Received message for unknown route ID {message.route_id}. Ignoring.")
161
+ return
162
+
163
+ response = await relevant_route.callback(message)
164
+
165
+ if message.command_type == AttpCommand.CALL:
166
+ if not self.session:
167
+ raise DeadSessionError(self.organization_id)
168
+
169
+ if not message.correlation_id:
170
+ await self.session.send_error(IErr(
171
+ detail={"message": "Correlation ID was missing in the message.", "code": "MissingCorrelationId"},
172
+ ))
173
+ return
174
+
175
+ await self.session.respond(
176
+ route=message.route_id,
177
+ correlation_id=message.correlation_id,
178
+ payload=response
179
+ )
180
+
181
+ def add_event_handler(
182
+ self,
183
+ pattern: str,
184
+ route_type: RouteType,
185
+ callback: Callable[..., Any],
186
+ ):
187
+ if route_type in ["connect", "disconnect"]:
188
+ self.routes.append(
189
+ AttpRouteMapping(
190
+ pattern=pattern,
191
+ route_id=0,
192
+ route_type=route_type,
193
+ callback=callback
194
+ )
195
+ )
196
+ return
197
+
198
+ self.routes.append(
199
+ AttpRouteMapping(
200
+ pattern=pattern,
201
+ route_id=self.route_increment_index,
202
+ route_type=route_type,
203
+ callback=callback
204
+ )
205
+ )
206
+
207
+ self.route_increment_index += 1
@@ -18,6 +18,79 @@ class AttpInferenceAPI:
18
18
  self.router = router
19
19
  self.logger = logger
20
20
 
21
+ async def create_chat(
22
+ self,
23
+ name: str,
24
+ agent_id: int | None = None,
25
+ agent_name: str | None = None,
26
+ mode: str = "agent_autopilot",
27
+ platform: str = "unknown",
28
+ responsible: int | None = None,
29
+ client_id: str | None = None,
30
+ created_by_id: int | None = None,
31
+ timeout: float = 30
32
+ ):
33
+ """
34
+ Create chat for inference.
35
+ TODO: Implement own chat manager for chats, just like I did with catalogs.
36
+ """
37
+ response = await self.router.send(
38
+ "messages:chat:create",
39
+ Serializable[dict[str, Any]]({
40
+ "name": name,
41
+ "agent_id": agent_id,
42
+ "agent_name": agent_name,
43
+ "mode": mode,
44
+ "platform": platform,
45
+ "responsible": responsible,
46
+ "client_id": client_id,
47
+ "created_by_id": created_by_id
48
+ }),
49
+ timeout=timeout,
50
+ expected_response=dict[str, Any]
51
+ )
52
+
53
+ return response
54
+
55
+ async def change_chat_agent(
56
+ self,
57
+ chat_id: UUID,
58
+ agent_id: int | None = None,
59
+ agent_name: str | None = None,
60
+ timeout: float = 30
61
+ ) -> dict[str, Any]:
62
+ """
63
+ Change the agent associated with a chat.
64
+
65
+ Parameters
66
+ ----------
67
+ chat_id : UUID
68
+ The ID of the chat to change the agent for.
69
+ agent_id : int | None, optional
70
+ The ID of the new agent to associate with the chat, by default None.
71
+ agent_name : str | None, optional
72
+ The name of the new agent to associate with the chat, by default None.
73
+ timeout : float, optional
74
+ The timeout for the request, by default 30.
75
+
76
+ Returns
77
+ -------
78
+ dict[str, Any]
79
+ The response from the change agent request.
80
+ """
81
+ response = await self.router.send(
82
+ "messages:chat:change_agent",
83
+ Serializable[dict[str, Any]]({
84
+ "chat_id": str(chat_id),
85
+ "agent_id": agent_id,
86
+ "agent_name": agent_name
87
+ }),
88
+ timeout=timeout,
89
+ expected_response=dict[str, Any]
90
+ )
91
+
92
+ return response
93
+
21
94
  async def invoke_inference(
22
95
  self,
23
96
  agent_id: int | None = None,
@@ -215,7 +215,7 @@ class SessionDriver:
215
215
  )
216
216
  )
217
217
 
218
- async def respond(self, correlation_id: bytes, payload: FixedBaseModel | Any | None = None):
218
+ async def respond(self, route: int | str, correlation_id: bytes, payload: FixedBaseModel | Serializable | Any | None = None):
219
219
  """
220
220
  For responding to `AttpCommand.CALL`. Used only for correlated requests.
221
221
  It sends response (acknowledgement) message signed as `AttpCommand.ACK` to the request.
@@ -227,8 +227,16 @@ class SessionDriver:
227
227
  payload : FixedBaseModel | Serializable | None, optional
228
228
  Response payload, the data that will be sent, by default None
229
229
  """
230
+ relevant_route = route
231
+
232
+ if not self.server_routes:
233
+ raise UnauthenticatedError(f"Cannot send an ATTP message with acknowledgement to unauthenticated (route_mapping={route})")
234
+
235
+ if isinstance(route, str):
236
+ relevant_route = resolve_route_by_id("message", route, self.server_routes).route_id
237
+
230
238
  frame = PyAttpMessage(
231
- route_id=0,
239
+ route_id=int(relevant_route),
232
240
  command_type=AttpCommand.ACK,
233
241
  correlation_id=correlation_id,
234
242
  payload=serializer.deserialize(payload),
@@ -310,6 +318,7 @@ class SessionDriver:
310
318
  if not self.session:
311
319
  raise DeadSessionError(self.organization_id)
312
320
  self.session.add_event_handler(self._on_event)
321
+
313
322
  await asyncio.gather(
314
323
  self.session.start_handler(),
315
324
  self.session.start_listener()
@@ -51,7 +51,7 @@ class ToolsManager:
51
51
  tool_id: str | Sequence[str]
52
52
  ) -> str | list[str]:
53
53
  response = await self.router.send(
54
- "tool:unregister",
54
+ "tools:unregister",
55
55
  Serializable[dict[str, Any]]({
56
56
  "catalog": catalog_name,
57
57
  "tool_id": tool_id
@@ -0,0 +1,9 @@
1
+ from attp_client.interfaces.catalogs.tools.envelope import IEnvelope
2
+ from attp_core.rs_api import PyAttpMessage
3
+
4
+
5
+ def envelopize(message: PyAttpMessage) -> IEnvelope:
6
+ if not message.payload:
7
+ raise ValueError("Message payload is empty, cannot envelopize.")
8
+
9
+ return IEnvelope.mps(message.payload)
@@ -1,119 +0,0 @@
1
- import asyncio
2
- from functools import cached_property
3
- from logging import Logger, getLogger
4
- from typing import Any, Callable, Literal
5
- from attp_core.rs_api import AttpClientSession, Limits
6
- from reactivex import Subject
7
- from attp_core.rs_api import PyAttpMessage
8
-
9
- from attp_client.catalog import AttpCatalog
10
- from attp_client.inference import AttpInferenceAPI
11
- from attp_client.interfaces.catalogs.catalog import ICatalogResponse
12
- from attp_client.misc.serializable import Serializable
13
- from attp_client.router import AttpRouter
14
- from attp_client.session import SessionDriver
15
- from attp_client.tools import ToolsManager
16
- from attp_client.types.route_mapping import AttpRouteMapping, RouteType
17
-
18
- class ATTPClient:
19
-
20
- is_connected: bool
21
- client: AttpClientSession
22
- session: SessionDriver | None
23
- routes: list[AttpRouteMapping]
24
- inference: AttpInferenceAPI
25
-
26
- def __init__(
27
- self,
28
- agt_token: str,
29
- organization_id: int,
30
- *,
31
- connection_url: str | None = None,
32
- max_retries: int = 20,
33
- limits: Limits | None = None,
34
- logger: Logger | None = None
35
- ):
36
- self.__agt_token = agt_token
37
- self.organization_id = organization_id
38
- self.connection_url = connection_url or "attp://localhost:6563"
39
-
40
- self.client = AttpClientSession(self.connection_url)
41
- self.session = None
42
- self.max_retries = max_retries
43
- self.limits = limits or Limits(max_payload_size=50000)
44
- self.logger = logger
45
-
46
- self.route_increment_index = 2
47
-
48
- self.responder = Subject[PyAttpMessage]()
49
- self.routes = []
50
-
51
- async def connect(self):
52
- # Open the connection
53
- client = await self.client.connect(self.max_retries, self.limits)
54
-
55
- if not client.session:
56
- raise ConnectionError("Failed to connect to ATTP server after 10 attempts!")
57
-
58
- self.session = SessionDriver(
59
- client.session,
60
- agt_token=self.__agt_token,
61
- organization_id=self.organization_id,
62
- # route_mappings=self.routes,
63
- logger=self.logger or getLogger("Ascender Framework")
64
- )
65
- asyncio.create_task(self.session.start_listener())
66
- # Send an authentication frame as soon as connection estabilishes with agenthub
67
- await self.session.authenticate(self.routes)
68
- asyncio.create_task(self.session.listen(self.responder))
69
-
70
- self.router = AttpRouter(self.responder, self.session)
71
- self.inference = AttpInferenceAPI(self.router)
72
-
73
- async def close(self):
74
- if self.session:
75
- await self.session.close()
76
- self.session = None
77
- self.is_connected = False
78
-
79
- @cached_property
80
- def tools(self):
81
- return ToolsManager(self.router)
82
-
83
- async def catalog(self, catalog_name: str):
84
- catalog = await self.router.send(
85
- "tools:catalogs:specific",
86
- Serializable[dict[str, str]]({"catalog_name": catalog_name}),
87
- timeout=10,
88
- expected_response=ICatalogResponse
89
- )
90
-
91
- return AttpCatalog(id=catalog.catalog_id, catalog_name=catalog_name, manager=self.tools)
92
-
93
- def add_event_handler(
94
- self,
95
- pattern: str,
96
- route_type: RouteType,
97
- callback: Callable[..., Any],
98
- ):
99
- if route_type in ["connect", "disconnect"]:
100
- self.routes.append(
101
- AttpRouteMapping(
102
- pattern=pattern,
103
- route_id=0,
104
- route_type=route_type,
105
- callback=callback
106
- )
107
- )
108
- return
109
-
110
- self.routes.append(
111
- AttpRouteMapping(
112
- pattern=pattern,
113
- route_id=self.route_increment_index,
114
- route_type=route_type,
115
- callback=callback
116
- )
117
- )
118
-
119
- self.route_increment_index += 1
File without changes