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.
- inter_agent/__init__.py +0 -0
- inter_agent/core/__init__.py +1 -0
- inter_agent/core/adapter_control.py +350 -0
- inter_agent/core/auth.py +279 -0
- inter_agent/core/channels.py +131 -0
- inter_agent/core/client.py +345 -0
- inter_agent/core/config.py +325 -0
- inter_agent/core/errors.py +31 -0
- inter_agent/core/kick.py +111 -0
- inter_agent/core/list.py +127 -0
- inter_agent/core/publish.py +146 -0
- inter_agent/core/router.py +8 -0
- inter_agent/core/send.py +265 -0
- inter_agent/core/server.py +765 -0
- inter_agent/core/shared.py +182 -0
- inter_agent/core/shutdown.py +92 -0
- inter_agent/core/status.py +291 -0
- inter_agent/core/tls.py +141 -0
- inter_agent/core/transport.py +37 -0
- inter_agent/py.typed +0 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/asyncapi.yaml +225 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/error-codes.md +28 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_challenge.json +6 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/auth_response.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/broadcast.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/bye.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/channels_ok.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/custom.unknown-pass-through.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/error.auth-failed.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent-with-label.json +14 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/hello.agent.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/kick_ok.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/list_ok.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.custom.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/msg.text.json +9 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/ping.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/pong.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/publish.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/send.direct.json +5 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/shutdown_ok.json +3 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/subscribe_ok.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/unsubscribe_ok.json +4 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/examples/welcome.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_challenge.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/auth_response.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/broadcast.json +18 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/bye.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/channels_ok.json +26 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/custom.json +22 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/error.json +37 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/hello.json +59 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick.json +13 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/kick_ok.json +12 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/list_ok.json +24 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/msg.json +29 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/ping.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/pong.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/publish.json +23 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/send.json +22 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/shutdown_ok.json +10 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe.json +15 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/subscribe_ok.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe.json +15 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/unsubscribe_ok.json +11 -0
- inter_agent_core-0.2.0.data/data/share/inter-agent/spec/schemas/welcome.json +36 -0
- inter_agent_core-0.2.0.dist-info/METADATA +85 -0
- inter_agent_core-0.2.0.dist-info/RECORD +80 -0
- inter_agent_core-0.2.0.dist-info/WHEEL +5 -0
- inter_agent_core-0.2.0.dist-info/entry_points.txt +10 -0
- inter_agent_core-0.2.0.dist-info/licenses/LICENSE.md +21 -0
- 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)
|
inter_agent/core/kick.py
ADDED
|
@@ -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())
|
inter_agent/core/list.py
ADDED
|
@@ -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())
|