inter-agent-core 0.2.0__py3-none-any.whl

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 (80) hide show
  1. inter_agent/__init__.py +0 -0
  2. inter_agent/core/__init__.py +1 -0
  3. inter_agent/core/adapter_control.py +350 -0
  4. inter_agent/core/auth.py +279 -0
  5. inter_agent/core/channels.py +131 -0
  6. inter_agent/core/client.py +345 -0
  7. inter_agent/core/config.py +325 -0
  8. inter_agent/core/errors.py +31 -0
  9. inter_agent/core/kick.py +111 -0
  10. inter_agent/core/list.py +127 -0
  11. inter_agent/core/publish.py +146 -0
  12. inter_agent/core/router.py +8 -0
  13. inter_agent/core/send.py +265 -0
  14. inter_agent/core/server.py +765 -0
  15. inter_agent/core/shared.py +182 -0
  16. inter_agent/core/shutdown.py +92 -0
  17. inter_agent/core/status.py +291 -0
  18. inter_agent/core/tls.py +141 -0
  19. inter_agent/core/transport.py +37 -0
  20. inter_agent/py.typed +0 -0
  21. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
  22. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
  23. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
  24. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
  25. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
  26. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
  27. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
  28. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
  29. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
  30. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
  31. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
  32. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
  33. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
  34. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
  35. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
  36. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
  37. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
  38. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
  39. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
  40. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
  41. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
  42. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
  43. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
  44. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
  45. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
  46. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
  47. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
  48. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
  49. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
  50. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
  51. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
  52. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
  53. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
  54. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
  55. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
  56. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
  57. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
  58. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
  59. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
  60. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
  61. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
  62. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
  63. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
  64. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
  65. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
  66. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
  67. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
  68. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
  69. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
  70. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
  71. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
  72. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
  73. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
  74. inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
  75. inter_agent_core-0.2.0.dist-info/METADATA +85 -0
  76. inter_agent_core-0.2.0.dist-info/RECORD +80 -0
  77. inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
  78. inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
  79. inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
  80. inter_agent_core-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,146 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ import uuid
8
+ from collections.abc import Sequence
9
+ from pathlib import Path
10
+
11
+ import websockets
12
+ from websockets.asyncio.client import ClientConnection
13
+
14
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
15
+ from inter_agent.core.send import ProtocolErrorResult, SendResult
16
+ from inter_agent.core.shared import (
17
+ Limits,
18
+ control_hello,
19
+ resolve_endpoint,
20
+ resolve_shared_secret,
21
+ validate_channel_name,
22
+ )
23
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
24
+
25
+
26
+ def _text_frame(frame: str | bytes) -> str:
27
+ if isinstance(frame, bytes):
28
+ return frame.decode("utf-8")
29
+ return frame
30
+
31
+
32
+ def _json_object(raw: str) -> dict[str, object]:
33
+ payload: object = json.loads(raw)
34
+ if not isinstance(payload, dict):
35
+ raise ValueError("server response must be a JSON object")
36
+ return {str(key): value for key, value in payload.items()}
37
+
38
+
39
+ def _protocol_error_from_payload(
40
+ raw: str, payload: dict[str, object]
41
+ ) -> ProtocolErrorResult | None:
42
+ if payload.get("op") != "error":
43
+ return None
44
+ code = payload.get("code")
45
+ message = payload.get("message")
46
+ return ProtocolErrorResult(
47
+ code=code if isinstance(code, str) else "PROTOCOL_ERROR",
48
+ message=message if isinstance(message, str) else "protocol error",
49
+ raw=raw,
50
+ )
51
+
52
+
53
+ async def _recv_protocol_error(ws: ClientConnection, timeout: float) -> ProtocolErrorResult | None:
54
+ try:
55
+ raw = _text_frame(await asyncio.wait_for(ws.recv(), timeout=timeout))
56
+ except TimeoutError:
57
+ return None
58
+
59
+ response = _json_object(raw)
60
+ return _protocol_error_from_payload(raw, response)
61
+
62
+
63
+ async def publish_to_channel(
64
+ host: str,
65
+ port: int,
66
+ channel: str,
67
+ text: str,
68
+ from_name: str | None = None,
69
+ *,
70
+ tls: bool = False,
71
+ data_dir: Path | None = None,
72
+ tls_cert_path: Path | None = None,
73
+ ) -> SendResult:
74
+ """Publish a text message to a channel through a control connection."""
75
+ if not validate_channel_name(channel, Limits().channel_name_max):
76
+ raise ValueError(f"invalid channel name: {channel!r}")
77
+ secret = resolve_shared_secret().secret
78
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
79
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
80
+ try:
81
+ welcome = await client_handshake(ws, secret, control_hello(f"pub-{uuid.uuid4()}"))
82
+ except AuthError as exc:
83
+ raise SystemExit(str(exc)) from exc
84
+ except (AuthProtocolError, json.JSONDecodeError, UnicodeDecodeError) as exc:
85
+ raise SystemExit(f"server protocol mismatch: {exc}") from exc
86
+ welcome_payload = _json_object(welcome)
87
+ welcome_error = _protocol_error_from_payload(welcome, welcome_payload)
88
+ if welcome_error is not None:
89
+ return SendResult(welcome=welcome, welcome_payload=welcome_payload, error=welcome_error)
90
+ outbound: dict[str, object] = {"op": "publish", "channel": channel, "text": text}
91
+ if from_name:
92
+ outbound["from_name"] = from_name
93
+ await ws.send(json.dumps(outbound))
94
+ return SendResult(
95
+ welcome=welcome,
96
+ welcome_payload=welcome_payload,
97
+ error=await _recv_protocol_error(ws, 0.1),
98
+ )
99
+
100
+
101
+ def build_parser() -> argparse.ArgumentParser:
102
+ parser = argparse.ArgumentParser(prog="inter-agent-publish")
103
+ parser.add_argument("channel")
104
+ parser.add_argument("text")
105
+ parser.add_argument("--from", dest="from_name")
106
+ parser.add_argument("--host")
107
+ parser.add_argument("--port", type=int)
108
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
109
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
110
+ parser.add_argument("--tls-cert")
111
+ return parser
112
+
113
+
114
+ def main(argv: Sequence[str] | None = None) -> int:
115
+ parser = build_parser()
116
+ args = parser.parse_args(argv)
117
+ endpoint = resolve_endpoint(
118
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
119
+ )
120
+ try:
121
+ result = asyncio.run(
122
+ publish_to_channel(
123
+ endpoint.host,
124
+ endpoint.port,
125
+ args.channel,
126
+ args.text,
127
+ args.from_name,
128
+ tls=endpoint.tls,
129
+ data_dir=endpoint.data_dir,
130
+ tls_cert_path=endpoint.tls_cert_path,
131
+ )
132
+ )
133
+ except ValueError as exc:
134
+ print(f"inter-agent: {exc}", file=sys.stderr)
135
+ return 1
136
+ if result.error is not None:
137
+ print(
138
+ f"inter-agent: publish failed ({result.error.code}): {result.error.message}",
139
+ file=sys.stderr,
140
+ )
141
+ return 1
142
+ return 0
143
+
144
+
145
+ if __name__ == "__main__":
146
+ raise SystemExit(main())
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class RouterMiddleware:
5
+ """Future hook point (rate limits, channels, policy)."""
6
+
7
+ async def before_route(self, _sender_session_id: str, _message: dict[str, object]) -> None:
8
+ return None
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import sys
7
+ import uuid
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import websockets
13
+ from websockets.asyncio.client import ClientConnection
14
+
15
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
16
+ from inter_agent.core.shared import control_hello, resolve_endpoint, resolve_shared_secret
17
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ProtocolErrorResult:
22
+ """Protocol error received after sending an outbound payload."""
23
+
24
+ code: str
25
+ message: str
26
+ raw: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class SendResult:
31
+ """Result returned after a command connection sends one outbound payload."""
32
+
33
+ welcome: str
34
+ welcome_payload: dict[str, object]
35
+ error: ProtocolErrorResult | None = None
36
+
37
+
38
+ def _text_frame(frame: str | bytes) -> str:
39
+ if isinstance(frame, bytes):
40
+ return frame.decode("utf-8")
41
+ return frame
42
+
43
+
44
+ def _json_object(raw: str) -> dict[str, object]:
45
+ payload: object = json.loads(raw)
46
+ if not isinstance(payload, dict):
47
+ raise ValueError("server response must be a JSON object")
48
+ return {str(key): value for key, value in payload.items()}
49
+
50
+
51
+ def _protocol_error_from_payload(
52
+ raw: str, payload: dict[str, object]
53
+ ) -> ProtocolErrorResult | None:
54
+ if payload.get("op") != "error":
55
+ return None
56
+ code = payload.get("code")
57
+ message = payload.get("message")
58
+ return ProtocolErrorResult(
59
+ code=code if isinstance(code, str) else "PROTOCOL_ERROR",
60
+ message=message if isinstance(message, str) else "protocol error",
61
+ raw=raw,
62
+ )
63
+
64
+
65
+ def parse_custom_payload(payload: str | None) -> object:
66
+ """Parse a CLI custom payload string into a JSON-compatible value."""
67
+ if payload is None:
68
+ return {}
69
+ parsed: object = json.loads(payload)
70
+ return parsed
71
+
72
+
73
+ async def _recv_protocol_error(ws: ClientConnection, timeout: float) -> ProtocolErrorResult | None:
74
+ try:
75
+ raw = _text_frame(await asyncio.wait_for(ws.recv(), timeout=timeout))
76
+ except TimeoutError:
77
+ return None
78
+
79
+ response = _json_object(raw)
80
+ return _protocol_error_from_payload(raw, response)
81
+
82
+
83
+ async def send_message(
84
+ host: str,
85
+ port: int,
86
+ to: str | None,
87
+ text: str | None,
88
+ custom_type: str | None,
89
+ payload: object | None,
90
+ from_name: str | None = None,
91
+ response_timeout: float = 0.1,
92
+ *,
93
+ tls: bool = False,
94
+ data_dir: Path | None = None,
95
+ tls_cert_path: Path | None = None,
96
+ ) -> SendResult:
97
+ """Send a direct, broadcast, or custom message through a control connection."""
98
+ secret = resolve_shared_secret().secret
99
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
100
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
101
+ try:
102
+ welcome = await client_handshake(ws, secret, control_hello(f"ctl-{uuid.uuid4()}"))
103
+ except AuthError as exc:
104
+ raise SystemExit(str(exc)) from exc
105
+ except (AuthProtocolError, json.JSONDecodeError, UnicodeDecodeError) as exc:
106
+ raise SystemExit(f"server protocol mismatch: {exc}") from exc
107
+ welcome_payload = _json_object(welcome)
108
+ welcome_error = _protocol_error_from_payload(welcome, welcome_payload)
109
+ if welcome_error is not None:
110
+ return SendResult(welcome=welcome, welcome_payload=welcome_payload, error=welcome_error)
111
+ if custom_type is not None:
112
+ msg: dict[str, object] = {
113
+ "op": "custom",
114
+ "custom_type": custom_type,
115
+ "payload": {} if payload is None else payload,
116
+ }
117
+ if to:
118
+ msg["to"] = to
119
+ if from_name:
120
+ msg["from_name"] = from_name
121
+ await ws.send(json.dumps(msg))
122
+ elif to:
123
+ outbound: dict[str, object] = {"op": "send", "to": to, "text": text or ""}
124
+ if from_name:
125
+ outbound["from_name"] = from_name
126
+ await ws.send(json.dumps(outbound))
127
+ else:
128
+ outbound = {"op": "broadcast", "text": text or ""}
129
+ if from_name:
130
+ outbound["from_name"] = from_name
131
+ await ws.send(json.dumps(outbound))
132
+ return SendResult(
133
+ welcome=welcome,
134
+ welcome_payload=welcome_payload,
135
+ error=await _recv_protocol_error(ws, response_timeout),
136
+ )
137
+
138
+
139
+ async def send_direct_message(
140
+ host: str,
141
+ port: int,
142
+ to: str,
143
+ text: str,
144
+ from_name: str | None = None,
145
+ *,
146
+ tls: bool = False,
147
+ data_dir: Path | None = None,
148
+ tls_cert_path: Path | None = None,
149
+ ) -> SendResult:
150
+ """Send a direct text message to one routing name."""
151
+ return await send_message(
152
+ host,
153
+ port,
154
+ to,
155
+ text,
156
+ custom_type=None,
157
+ payload=None,
158
+ from_name=from_name,
159
+ tls=tls,
160
+ data_dir=data_dir,
161
+ tls_cert_path=tls_cert_path,
162
+ )
163
+
164
+
165
+ async def broadcast_message(
166
+ host: str,
167
+ port: int,
168
+ text: str,
169
+ from_name: str | None = None,
170
+ *,
171
+ tls: bool = False,
172
+ data_dir: Path | None = None,
173
+ tls_cert_path: Path | None = None,
174
+ ) -> SendResult:
175
+ """Broadcast a text message to all other connected agent sessions."""
176
+ return await send_message(
177
+ host,
178
+ port,
179
+ to=None,
180
+ text=text,
181
+ custom_type=None,
182
+ payload=None,
183
+ from_name=from_name,
184
+ tls=tls,
185
+ data_dir=data_dir,
186
+ tls_cert_path=tls_cert_path,
187
+ )
188
+
189
+
190
+ async def send_custom_message(
191
+ host: str,
192
+ port: int,
193
+ to: str | None,
194
+ custom_type: str,
195
+ payload: object,
196
+ *,
197
+ tls: bool = False,
198
+ data_dir: Path | None = None,
199
+ tls_cert_path: Path | None = None,
200
+ ) -> SendResult:
201
+ """Send a custom protocol envelope through a control connection."""
202
+ return await send_message(
203
+ host,
204
+ port,
205
+ to,
206
+ text=None,
207
+ custom_type=custom_type,
208
+ payload=payload,
209
+ tls=tls,
210
+ data_dir=data_dir,
211
+ tls_cert_path=tls_cert_path,
212
+ )
213
+
214
+
215
+ def build_parser() -> argparse.ArgumentParser:
216
+ parser = argparse.ArgumentParser(prog="inter-agent-send")
217
+ parser.add_argument("to", nargs="?")
218
+ parser.add_argument("text", nargs="?")
219
+ parser.add_argument("--host")
220
+ parser.add_argument("--port", type=int)
221
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
222
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
223
+ parser.add_argument("--tls-cert")
224
+ parser.add_argument("--to", dest="to_option")
225
+ parser.add_argument("--text", dest="text_option")
226
+ parser.add_argument("--custom-type")
227
+ parser.add_argument("--payload")
228
+ parser.add_argument("--from", dest="from_name")
229
+ return parser
230
+
231
+
232
+ def main(argv: Sequence[str] | None = None) -> int:
233
+ parser = build_parser()
234
+ args = parser.parse_args(argv)
235
+ to = args.to_option or args.to
236
+ text = args.text_option if args.text_option is not None else args.text
237
+ payload = parse_custom_payload(args.payload) if args.custom_type is not None else None
238
+ endpoint = resolve_endpoint(
239
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
240
+ )
241
+ result = asyncio.run(
242
+ send_message(
243
+ endpoint.host,
244
+ endpoint.port,
245
+ to,
246
+ text,
247
+ args.custom_type,
248
+ payload,
249
+ args.from_name,
250
+ tls=endpoint.tls,
251
+ data_dir=endpoint.data_dir,
252
+ tls_cert_path=endpoint.tls_cert_path,
253
+ )
254
+ )
255
+ if result.error is not None:
256
+ print(
257
+ f"inter-agent: delivery failed ({result.error.code}): {result.error.message}",
258
+ file=sys.stderr,
259
+ )
260
+ return 1
261
+ return 0
262
+
263
+
264
+ if __name__ == "__main__":
265
+ raise SystemExit(main())