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,325 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import json
5
+ import os
6
+ import socket
7
+ import sys
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ BUILTIN_DEFAULT_HOST = "127.0.0.1"
12
+ BUILTIN_DEFAULT_PORT = 16837
13
+
14
+
15
+ class ConfigError(ValueError):
16
+ """Raised when inter-agent configuration cannot be interpreted."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class EndpointResolution:
21
+ """Resolved inter-agent endpoint and state location."""
22
+
23
+ host: str
24
+ port: int
25
+ data_dir: Path
26
+ host_source: str
27
+ port_source: str
28
+ data_dir_source: str
29
+ config_path: Path | None
30
+ configured_host: str
31
+ configured_port: int
32
+ scheme: str
33
+ tls: bool
34
+ tls_source: str
35
+ tls_cert_path: Path | None
36
+ tls_cert_source: str | None
37
+ tls_key_path: Path | None
38
+ tls_key_source: str | None
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ExplicitSecretResolution:
43
+ """Resolved explicit shared secret from environment or config."""
44
+
45
+ secret: str
46
+ source: str
47
+ config_path: Path | None
48
+
49
+
50
+ def _expand_path(raw: str) -> Path:
51
+ return Path(os.path.expandvars(os.path.expanduser(raw)))
52
+
53
+
54
+ def _platform_config_path() -> Path:
55
+ if sys.platform == "darwin":
56
+ return Path.home() / "Library" / "Application Support" / "inter-agent" / "config.json"
57
+ if sys.platform.startswith("win"):
58
+ base = os.getenv("APPDATA")
59
+ if base:
60
+ return Path(base) / "inter-agent" / "config.json"
61
+ xdg_config_home = os.getenv("XDG_CONFIG_HOME")
62
+ if xdg_config_home:
63
+ return Path(xdg_config_home) / "inter-agent" / "config.json"
64
+ return Path.home() / ".config" / "inter-agent" / "config.json"
65
+
66
+
67
+ def default_config_path() -> Path:
68
+ """Return the platform-native inter-agent config file path."""
69
+ override = os.getenv("INTER_AGENT_CONFIG")
70
+ if override:
71
+ return _expand_path(override)
72
+ return _platform_config_path()
73
+
74
+
75
+ def _platform_data_dir() -> Path:
76
+ if sys.platform == "darwin":
77
+ return Path.home() / "Library" / "Application Support" / "inter-agent"
78
+ if sys.platform.startswith("win"):
79
+ base = os.getenv("LOCALAPPDATA") or os.getenv("APPDATA")
80
+ if base:
81
+ return Path(base) / "inter-agent"
82
+ xdg_state_home = os.getenv("XDG_STATE_HOME")
83
+ if xdg_state_home:
84
+ return Path(xdg_state_home) / "inter-agent"
85
+ return Path.home() / ".local" / "state" / "inter-agent"
86
+
87
+
88
+ def _load_config() -> tuple[dict[str, object], Path | None]:
89
+ path = default_config_path()
90
+ if not path.exists():
91
+ return {}, None
92
+ try:
93
+ payload: object = json.loads(path.read_text(encoding="utf-8"))
94
+ except (OSError, json.JSONDecodeError) as exc:
95
+ raise ConfigError(f"invalid inter-agent config file {path}: {exc}") from exc
96
+ if not isinstance(payload, dict):
97
+ raise ConfigError(f"invalid inter-agent config file {path}: expected JSON object")
98
+ return {str(key): value for key, value in payload.items()}, path
99
+
100
+
101
+ def _config_string(config: dict[str, object], key: str) -> str | None:
102
+ value = config.get(key)
103
+ if value is None:
104
+ return None
105
+ if not isinstance(value, str):
106
+ raise ConfigError(f"inter-agent config key {key!r} must be a string")
107
+ return value
108
+
109
+
110
+ def _config_port(config: dict[str, object]) -> int | None:
111
+ value = config.get("port")
112
+ if value is None:
113
+ return None
114
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
115
+ raise ConfigError("inter-agent config key 'port' must be an integer")
116
+ try:
117
+ port = int(value)
118
+ except ValueError as exc:
119
+ raise ConfigError("inter-agent config key 'port' must be an integer") from exc
120
+ return _validate_port(port, "inter-agent config key 'port'")
121
+
122
+
123
+ def _env_port() -> int | None:
124
+ raw = os.getenv("INTER_AGENT_PORT")
125
+ if raw is None or raw == "":
126
+ return None
127
+ try:
128
+ port = int(raw)
129
+ except ValueError as exc:
130
+ raise ConfigError("INTER_AGENT_PORT must be an integer") from exc
131
+ return _validate_port(port, "INTER_AGENT_PORT")
132
+
133
+
134
+ def _parse_bool(value: object, source: str) -> bool:
135
+ if isinstance(value, bool):
136
+ return value
137
+ if not isinstance(value, str):
138
+ raise ConfigError(f"{source} must be a boolean")
139
+ normalized = value.strip().lower()
140
+ if normalized in {"1", "true", "yes", "on", "wss", "tls"}:
141
+ return True
142
+ if normalized in {"0", "false", "no", "off", "ws", "plaintext"}:
143
+ return False
144
+ raise ConfigError(f"{source} must be a boolean")
145
+
146
+
147
+ def _config_bool(config: dict[str, object], key: str) -> bool | None:
148
+ value = config.get(key)
149
+ if value is None:
150
+ return None
151
+ return _parse_bool(value, f"inter-agent config key {key!r}")
152
+
153
+
154
+ def _env_bool(name: str) -> bool | None:
155
+ raw = os.getenv(name)
156
+ if raw is None or raw == "":
157
+ return None
158
+ return _parse_bool(raw, name)
159
+
160
+
161
+ def _is_loopback_host(host: str) -> bool:
162
+ if host == "localhost":
163
+ return True
164
+ try:
165
+ return ipaddress.ip_address(host).is_loopback
166
+ except ValueError:
167
+ pass
168
+ try:
169
+ return ipaddress.ip_address(socket.gethostbyname(host)).is_loopback
170
+ except Exception:
171
+ return False
172
+
173
+
174
+ def _validate_port(port: int, source: str) -> int:
175
+ if port < 1 or port > 65535:
176
+ raise ConfigError(f"{source} must be between 1 and 65535")
177
+ return port
178
+
179
+
180
+ def resolve_data_dir_path() -> Path:
181
+ """Resolve the inter-agent state/data directory without creating it."""
182
+ config, _ = _load_config()
183
+ env_data_dir = os.getenv("INTER_AGENT_DATA_DIR")
184
+ if env_data_dir:
185
+ return _expand_path(env_data_dir)
186
+ config_data_dir = _config_string(config, "dataDir")
187
+ if config_data_dir:
188
+ return _expand_path(config_data_dir)
189
+ return _platform_data_dir()
190
+
191
+
192
+ def resolve_explicit_secret_config() -> ExplicitSecretResolution | None:
193
+ """Resolve explicit shared secret from environment/config, if configured."""
194
+ config, path = _load_config()
195
+
196
+ env_secret = os.getenv("INTER_AGENT_SECRET")
197
+ if env_secret is not None:
198
+ if not env_secret.strip():
199
+ raise ConfigError("INTER_AGENT_SECRET must not be empty")
200
+ return ExplicitSecretResolution(secret=env_secret, source="env", config_path=path)
201
+
202
+ config_secret = _config_string(config, "secret")
203
+ if config_secret is not None:
204
+ if not config_secret.strip():
205
+ raise ConfigError("inter-agent config key 'secret' must not be empty")
206
+ return ExplicitSecretResolution(secret=config_secret, source="config", config_path=path)
207
+
208
+ return None
209
+
210
+
211
+ def resolve_endpoint_config(
212
+ cli_host: str | None = None,
213
+ cli_port: int | None = None,
214
+ cli_tls: bool | None = None,
215
+ cli_tls_cert_path: str | None = None,
216
+ cli_tls_key_path: str | None = None,
217
+ ) -> EndpointResolution:
218
+ """Resolve endpoint, TLS mode, and data directory from flags, env, config, defaults."""
219
+ config, path = _load_config()
220
+
221
+ config_host = _config_string(config, "host")
222
+ env_host = os.getenv("INTER_AGENT_HOST")
223
+ if cli_host:
224
+ host = cli_host
225
+ host_source = "cli"
226
+ elif env_host:
227
+ host = env_host
228
+ host_source = "env"
229
+ elif config_host:
230
+ host = config_host
231
+ host_source = "config"
232
+ else:
233
+ host = BUILTIN_DEFAULT_HOST
234
+ host_source = "default"
235
+
236
+ config_port = _config_port(config)
237
+ env_port = _env_port()
238
+ if cli_port is not None:
239
+ port = _validate_port(cli_port, "--port")
240
+ port_source = "cli"
241
+ elif env_port is not None:
242
+ port = env_port
243
+ port_source = "env"
244
+ elif config_port is not None:
245
+ port = config_port
246
+ port_source = "config"
247
+ else:
248
+ port = BUILTIN_DEFAULT_PORT
249
+ port_source = "default"
250
+
251
+ env_data_dir = os.getenv("INTER_AGENT_DATA_DIR")
252
+ config_data_dir = _config_string(config, "dataDir")
253
+ if env_data_dir:
254
+ data_dir = _expand_path(env_data_dir)
255
+ data_dir_source = "env"
256
+ elif config_data_dir:
257
+ data_dir = _expand_path(config_data_dir)
258
+ data_dir_source = "config"
259
+ else:
260
+ data_dir = _platform_data_dir()
261
+ data_dir_source = "default"
262
+
263
+ config_tls = _config_bool(config, "tls")
264
+ env_tls = _env_bool("INTER_AGENT_TLS")
265
+ if cli_tls is not None:
266
+ tls = cli_tls
267
+ tls_source = "cli"
268
+ elif env_tls is not None:
269
+ tls = env_tls
270
+ tls_source = "env"
271
+ elif config_tls is not None:
272
+ tls = config_tls
273
+ tls_source = "config"
274
+ else:
275
+ tls = not _is_loopback_host(host)
276
+ tls_source = "default"
277
+
278
+ config_tls_cert_path = _config_string(config, "tlsCert")
279
+ env_tls_cert_path = os.getenv("INTER_AGENT_TLS_CERT")
280
+ if cli_tls_cert_path:
281
+ tls_cert_path = _expand_path(cli_tls_cert_path)
282
+ tls_cert_source = "cli"
283
+ elif env_tls_cert_path:
284
+ tls_cert_path = _expand_path(env_tls_cert_path)
285
+ tls_cert_source = "env"
286
+ elif config_tls_cert_path:
287
+ tls_cert_path = _expand_path(config_tls_cert_path)
288
+ tls_cert_source = "config"
289
+ else:
290
+ tls_cert_path = None
291
+ tls_cert_source = None
292
+
293
+ config_tls_key_path = _config_string(config, "tlsKey")
294
+ env_tls_key_path = os.getenv("INTER_AGENT_TLS_KEY")
295
+ if cli_tls_key_path:
296
+ tls_key_path = _expand_path(cli_tls_key_path)
297
+ tls_key_source = "cli"
298
+ elif env_tls_key_path:
299
+ tls_key_path = _expand_path(env_tls_key_path)
300
+ tls_key_source = "env"
301
+ elif config_tls_key_path:
302
+ tls_key_path = _expand_path(config_tls_key_path)
303
+ tls_key_source = "config"
304
+ else:
305
+ tls_key_path = None
306
+ tls_key_source = None
307
+
308
+ return EndpointResolution(
309
+ host=host,
310
+ port=port,
311
+ data_dir=data_dir,
312
+ host_source=host_source,
313
+ port_source=port_source,
314
+ data_dir_source=data_dir_source,
315
+ config_path=path,
316
+ configured_host=host,
317
+ configured_port=port,
318
+ scheme="wss" if tls else "ws",
319
+ tls=tls,
320
+ tls_source=tls_source,
321
+ tls_cert_path=tls_cert_path,
322
+ tls_cert_source=tls_cert_source,
323
+ tls_key_path=tls_key_path,
324
+ tls_key_source=tls_key_source,
325
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class ErrorCode(Enum):
7
+ PROTOCOL_ERROR = "PROTOCOL_ERROR"
8
+ AUTH_FAILED = "AUTH_FAILED"
9
+ TOO_MANY_CONNECTIONS = "TOO_MANY_CONNECTIONS"
10
+ BAD_ROLE = "BAD_ROLE"
11
+ BAD_SESSION = "BAD_SESSION"
12
+ SESSION_TAKEN = "SESSION_TAKEN"
13
+ BAD_NAME = "BAD_NAME"
14
+ BAD_LABEL = "BAD_LABEL"
15
+ NAME_TAKEN = "NAME_TAKEN"
16
+ UNKNOWN_OP = "UNKNOWN_OP"
17
+ BAD_TEXT = "BAD_TEXT"
18
+ BAD_FROM_NAME = "BAD_FROM_NAME"
19
+ BAD_CUSTOM_TYPE = "BAD_CUSTOM_TYPE"
20
+ TEXT_TOO_LARGE = "TEXT_TOO_LARGE"
21
+ CUSTOM_PAYLOAD_TOO_LARGE = "CUSTOM_PAYLOAD_TOO_LARGE"
22
+ UNKNOWN_TARGET = "UNKNOWN_TARGET"
23
+ AMBIGUOUS_TARGET = "AMBIGUOUS_TARGET"
24
+ BAD_CHANNEL = "BAD_CHANNEL"
25
+ CHANNEL_LIMIT_REACHED = "CHANNEL_LIMIT_REACHED"
26
+ NOT_SUBSCRIBED = "NOT_SUBSCRIBED"
27
+ UNKNOWN_CHANNEL = "UNKNOWN_CHANNEL"
28
+ KICKED = "KICKED"
29
+
30
+
31
+ ERROR_CODE_VALUES: tuple[str, ...] = tuple(code.value for code in ErrorCode)
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import websockets
12
+
13
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
14
+ from inter_agent.core.shared import control_hello, resolve_endpoint, resolve_shared_secret
15
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class KickResult:
20
+ """Result returned after requesting a session kick."""
21
+
22
+ response: str
23
+ response_payload: dict[str, object]
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
+ async def kick_session(
40
+ host: str,
41
+ port: int,
42
+ *,
43
+ name: str | None = None,
44
+ session_id: str | None = None,
45
+ tls: bool = False,
46
+ data_dir: Path | None = None,
47
+ tls_cert_path: Path | None = None,
48
+ ) -> KickResult:
49
+ """Force-disconnect a registered session through a control connection.
50
+
51
+ Either ``name`` or ``session_id`` must be provided. ``name`` is unique for
52
+ agent sessions because the server rejects duplicate agent names.
53
+ """
54
+ if not name and not session_id:
55
+ raise ValueError("kick requires a name or session_id")
56
+ secret = resolve_shared_secret().secret
57
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
58
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
59
+ try:
60
+ _ = await client_handshake(ws, secret, control_hello(f"kick-{uuid.uuid4()}"))
61
+ except AuthError as exc:
62
+ raise SystemExit(str(exc)) from exc
63
+ except (AuthProtocolError, json.JSONDecodeError, UnicodeDecodeError) as exc:
64
+ raise SystemExit(f"server protocol mismatch: {exc}") from exc
65
+ msg: dict[str, object] = {"op": "kick"}
66
+ if name:
67
+ msg["name"] = name
68
+ if session_id:
69
+ msg["session_id"] = session_id
70
+ await ws.send(json.dumps(msg))
71
+ response = _text_frame(await ws.recv())
72
+ return KickResult(response=response, response_payload=_json_object(response))
73
+
74
+
75
+ def build_parser() -> argparse.ArgumentParser:
76
+ parser = argparse.ArgumentParser(prog="inter-agent-kick")
77
+ parser.add_argument("name", nargs="?")
78
+ parser.add_argument("--name", dest="name_option")
79
+ parser.add_argument("--session-id")
80
+ parser.add_argument("--host")
81
+ parser.add_argument("--port", type=int)
82
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
83
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
84
+ parser.add_argument("--tls-cert")
85
+ return parser
86
+
87
+
88
+ def main(argv: Sequence[str] | None = None) -> int:
89
+ parser = build_parser()
90
+ args = parser.parse_args(argv)
91
+ name = args.name_option or args.name
92
+ endpoint = resolve_endpoint(
93
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
94
+ )
95
+ result = asyncio.run(
96
+ kick_session(
97
+ endpoint.host,
98
+ endpoint.port,
99
+ name=name,
100
+ session_id=args.session_id,
101
+ tls=endpoint.tls,
102
+ data_dir=endpoint.data_dir,
103
+ tls_cert_path=endpoint.tls_cert_path,
104
+ )
105
+ )
106
+ print(result.response)
107
+ return 0 if result.response_payload.get("op") == "kick_ok" else 1
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import json
6
+ import uuid
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import websockets
12
+
13
+ from inter_agent.core.auth import AuthError, AuthProtocolError, client_handshake
14
+ from inter_agent.core.shared import control_hello, resolve_endpoint, resolve_shared_secret
15
+ from inter_agent.core.transport import client_ssl_context, websocket_uri
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class SessionInfo:
20
+ """Agent session returned by the core list operation."""
21
+
22
+ session_id: str
23
+ name: str
24
+ label: str | None
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ListResult:
29
+ """Structured list command result plus the raw protocol response."""
30
+
31
+ raw_response: str
32
+ response: dict[str, object]
33
+ sessions: tuple[SessionInfo, ...]
34
+
35
+
36
+ def _text_frame(frame: str | bytes) -> str:
37
+ if isinstance(frame, bytes):
38
+ return frame.decode("utf-8")
39
+ return frame
40
+
41
+
42
+ def _json_object(raw: str) -> dict[str, object]:
43
+ payload: object = json.loads(raw)
44
+ if not isinstance(payload, dict):
45
+ raise ValueError("server response must be a JSON object")
46
+ return {str(key): value for key, value in payload.items()}
47
+
48
+
49
+ def _parse_sessions(response: dict[str, object]) -> tuple[SessionInfo, ...]:
50
+ sessions = response.get("sessions")
51
+ if not isinstance(sessions, list):
52
+ raise ValueError("list response must include sessions")
53
+
54
+ result: list[SessionInfo] = []
55
+ for entry in sessions:
56
+ if not isinstance(entry, dict):
57
+ raise ValueError("list sessions must be objects")
58
+ session_id = entry.get("session_id")
59
+ name = entry.get("name")
60
+ label = entry.get("label")
61
+ if not isinstance(session_id, str) or not isinstance(name, str):
62
+ raise ValueError("list sessions must include string session_id and name")
63
+ if label is not None and not isinstance(label, str):
64
+ raise ValueError("list session label must be a string or null")
65
+ result.append(SessionInfo(session_id=session_id, name=name, label=label))
66
+ return tuple(result)
67
+
68
+
69
+ async def list_sessions(
70
+ host: str,
71
+ port: int,
72
+ *,
73
+ tls: bool = False,
74
+ data_dir: Path | None = None,
75
+ tls_cert_path: Path | None = None,
76
+ ) -> ListResult:
77
+ """Return connected agent sessions through a control connection."""
78
+ secret = resolve_shared_secret().secret
79
+ ssl_context = client_ssl_context(tls, data_dir, tls_cert_path)
80
+ async with websockets.connect(websocket_uri(host, port, tls), ssl=ssl_context) as ws:
81
+ try:
82
+ _ = await client_handshake(ws, secret, control_hello(f"ctl-{uuid.uuid4()}"))
83
+ except AuthError as exc:
84
+ raise SystemExit(str(exc)) from exc
85
+ except (AuthProtocolError, json.JSONDecodeError, UnicodeDecodeError) as exc:
86
+ raise SystemExit(f"server protocol mismatch: {exc}") from exc
87
+ await ws.send(json.dumps({"op": "list"}))
88
+ raw_response = _text_frame(await ws.recv())
89
+ response = _json_object(raw_response)
90
+ return ListResult(
91
+ raw_response=raw_response,
92
+ response=response,
93
+ sessions=_parse_sessions(response),
94
+ )
95
+
96
+
97
+ def build_parser() -> argparse.ArgumentParser:
98
+ parser = argparse.ArgumentParser(prog="inter-agent-list")
99
+ parser.add_argument("--host")
100
+ parser.add_argument("--port", type=int)
101
+ parser.add_argument("--tls", dest="tls", action="store_true", default=None)
102
+ parser.add_argument("--no-tls", dest="tls", action="store_false")
103
+ parser.add_argument("--tls-cert")
104
+ return parser
105
+
106
+
107
+ def main(argv: Sequence[str] | None = None) -> int:
108
+ parser = build_parser()
109
+ args = parser.parse_args(argv)
110
+ endpoint = resolve_endpoint(
111
+ args.host, args.port, allow_discovery=True, tls=args.tls, tls_cert_path=args.tls_cert
112
+ )
113
+ result = asyncio.run(
114
+ list_sessions(
115
+ endpoint.host,
116
+ endpoint.port,
117
+ tls=endpoint.tls,
118
+ data_dir=endpoint.data_dir,
119
+ tls_cert_path=endpoint.tls_cert_path,
120
+ )
121
+ )
122
+ print(result.raw_response)
123
+ return 0
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())