inter-agent-pi 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_pi/__init__.py +1 -0
- inter_agent_pi/cli.py +88 -0
- inter_agent_pi/commands.py +348 -0
- inter_agent_pi/listener.py +409 -0
- inter_agent_pi-0.2.0.dist-info/METADATA +228 -0
- inter_agent_pi-0.2.0.dist-info/RECORD +10 -0
- inter_agent_pi-0.2.0.dist-info/WHEEL +5 -0
- inter_agent_pi-0.2.0.dist-info/entry_points.txt +2 -0
- inter_agent_pi-0.2.0.dist-info/licenses/LICENSE.md +21 -0
- inter_agent_pi-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Pi adapter package."""
|
inter_agent_pi/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
|
|
6
|
+
from inter_agent_pi import commands
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
10
|
+
parser = argparse.ArgumentParser(prog="inter-agent-pi")
|
|
11
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
12
|
+
|
|
13
|
+
connect = sub.add_parser("connect")
|
|
14
|
+
connect.add_argument("name")
|
|
15
|
+
connect.add_argument("--label")
|
|
16
|
+
|
|
17
|
+
send = sub.add_parser("send")
|
|
18
|
+
send.add_argument("to")
|
|
19
|
+
send.add_argument("text")
|
|
20
|
+
send.add_argument("--from", dest="from_name")
|
|
21
|
+
|
|
22
|
+
broadcast = sub.add_parser("broadcast")
|
|
23
|
+
broadcast.add_argument("text")
|
|
24
|
+
broadcast.add_argument("--from", dest="from_name")
|
|
25
|
+
|
|
26
|
+
subscribe = sub.add_parser("subscribe")
|
|
27
|
+
subscribe.add_argument("channel")
|
|
28
|
+
subscribe.add_argument("--name", required=True)
|
|
29
|
+
|
|
30
|
+
unsubscribe = sub.add_parser("unsubscribe")
|
|
31
|
+
unsubscribe.add_argument("channel")
|
|
32
|
+
unsubscribe.add_argument("--name", required=True)
|
|
33
|
+
|
|
34
|
+
kick = sub.add_parser("kick")
|
|
35
|
+
kick.add_argument("name")
|
|
36
|
+
|
|
37
|
+
publish = sub.add_parser("publish")
|
|
38
|
+
publish.add_argument("channel")
|
|
39
|
+
publish.add_argument("text")
|
|
40
|
+
publish.add_argument("--from", dest="from_name")
|
|
41
|
+
|
|
42
|
+
channels = sub.add_parser("channels")
|
|
43
|
+
channels.add_argument("--json", action="store_true", help="emit JSON protocol output")
|
|
44
|
+
|
|
45
|
+
list_parser = sub.add_parser("list")
|
|
46
|
+
list_parser.add_argument("--json", action="store_true", help="emit JSON protocol output")
|
|
47
|
+
|
|
48
|
+
status = sub.add_parser("status")
|
|
49
|
+
status.add_argument("--json", action="store_true", help="emit JSON status output")
|
|
50
|
+
|
|
51
|
+
sub.add_parser("shutdown")
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
56
|
+
parser = build_parser()
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if args.command == "connect":
|
|
60
|
+
return commands.connect(args.name, args.label)
|
|
61
|
+
if args.command == "send":
|
|
62
|
+
return commands.send(args.to, args.text, args.from_name)
|
|
63
|
+
if args.command == "broadcast":
|
|
64
|
+
return commands.broadcast(args.text, args.from_name)
|
|
65
|
+
if args.command == "subscribe":
|
|
66
|
+
return commands.subscribe(args.channel, args.name)
|
|
67
|
+
if args.command == "unsubscribe":
|
|
68
|
+
return commands.unsubscribe(args.channel, args.name)
|
|
69
|
+
if args.command == "kick":
|
|
70
|
+
return commands.kick(args.name)
|
|
71
|
+
if args.command == "publish":
|
|
72
|
+
return commands.publish(args.channel, args.text, args.from_name)
|
|
73
|
+
if args.command == "channels":
|
|
74
|
+
return commands.channels(as_json=args.json)
|
|
75
|
+
if args.command == "list":
|
|
76
|
+
return commands.list_sessions()
|
|
77
|
+
if args.command == "status":
|
|
78
|
+
print(commands.status_json())
|
|
79
|
+
return 0
|
|
80
|
+
if args.command == "shutdown":
|
|
81
|
+
return commands.shutdown()
|
|
82
|
+
|
|
83
|
+
parser.print_help()
|
|
84
|
+
return 2
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Pi adapter wrappers around importable core command APIs.
|
|
2
|
+
|
|
3
|
+
Core supports `list`; adapter surfaces may choose whether to expose it.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from inter_agent.core import adapter_control as control
|
|
13
|
+
from inter_agent.core import channels as core_channels
|
|
14
|
+
from inter_agent.core import kick as core_kick
|
|
15
|
+
from inter_agent.core import list as core_list
|
|
16
|
+
from inter_agent.core import publish as core_publish
|
|
17
|
+
from inter_agent.core import send as core_send
|
|
18
|
+
from inter_agent.core import shutdown as core_shutdown
|
|
19
|
+
from inter_agent.core import status as core_status
|
|
20
|
+
from inter_agent.core.send import SendResult
|
|
21
|
+
from inter_agent.core.shared import Limits, resolve_endpoint, validate_channel_name
|
|
22
|
+
from websockets.exceptions import WebSocketException
|
|
23
|
+
|
|
24
|
+
from inter_agent_pi import listener
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _system_exit_code(exc: SystemExit) -> int:
|
|
28
|
+
if isinstance(exc.code, int):
|
|
29
|
+
return exc.code
|
|
30
|
+
if exc.code is not None:
|
|
31
|
+
print(exc.code, file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _expected_error_code(exc: Exception) -> int:
|
|
36
|
+
print(f"inter-agent-pi: {exc}", file=sys.stderr)
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _send_result_code(result: SendResult) -> int:
|
|
41
|
+
if result.error is not None:
|
|
42
|
+
print(
|
|
43
|
+
f"inter-agent-pi: delivery failed ({result.error.code}): {result.error.message}",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return 1
|
|
47
|
+
print(result.welcome)
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def connect(name: str, label: str | None = None) -> int:
|
|
52
|
+
try:
|
|
53
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
54
|
+
return asyncio.run(
|
|
55
|
+
listener.run_listener(
|
|
56
|
+
endpoint.host,
|
|
57
|
+
endpoint.port,
|
|
58
|
+
name,
|
|
59
|
+
label,
|
|
60
|
+
tls=endpoint.tls,
|
|
61
|
+
data_dir=endpoint.data_dir,
|
|
62
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
63
|
+
tls_key_path=endpoint.tls_key_path,
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
except SystemExit as exc:
|
|
67
|
+
return _system_exit_code(exc)
|
|
68
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
69
|
+
return _expected_error_code(exc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def send(to: str, text: str, from_name: str | None = None) -> int:
|
|
73
|
+
try:
|
|
74
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
75
|
+
result = asyncio.run(
|
|
76
|
+
core_send.send_direct_message(
|
|
77
|
+
endpoint.host,
|
|
78
|
+
endpoint.port,
|
|
79
|
+
to,
|
|
80
|
+
text,
|
|
81
|
+
from_name,
|
|
82
|
+
tls=endpoint.tls,
|
|
83
|
+
data_dir=endpoint.data_dir,
|
|
84
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
except SystemExit as exc:
|
|
88
|
+
return _system_exit_code(exc)
|
|
89
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
90
|
+
return _expected_error_code(exc)
|
|
91
|
+
return _send_result_code(result)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def broadcast(text: str, from_name: str | None = None) -> int:
|
|
95
|
+
try:
|
|
96
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
97
|
+
result = asyncio.run(
|
|
98
|
+
core_send.broadcast_message(
|
|
99
|
+
endpoint.host,
|
|
100
|
+
endpoint.port,
|
|
101
|
+
text,
|
|
102
|
+
from_name,
|
|
103
|
+
tls=endpoint.tls,
|
|
104
|
+
data_dir=endpoint.data_dir,
|
|
105
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
106
|
+
)
|
|
107
|
+
)
|
|
108
|
+
except SystemExit as exc:
|
|
109
|
+
return _system_exit_code(exc)
|
|
110
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
111
|
+
return _expected_error_code(exc)
|
|
112
|
+
return _send_result_code(result)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _control_response_code(response: dict[str, object]) -> int:
|
|
116
|
+
op = response.get("op")
|
|
117
|
+
if op in ("subscribe_ok", "unsubscribe_ok"):
|
|
118
|
+
print(json.dumps(response, ensure_ascii=False))
|
|
119
|
+
return 0
|
|
120
|
+
if op == "error":
|
|
121
|
+
code = response.get("code", "PROTOCOL_ERROR")
|
|
122
|
+
message = response.get("message", "protocol error")
|
|
123
|
+
print(f"inter-agent-pi: ({code}): {message}", file=sys.stderr)
|
|
124
|
+
return 1
|
|
125
|
+
print(f"inter-agent-pi: unexpected response: {response}", file=sys.stderr)
|
|
126
|
+
return 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _validate_channel_or_error(channel: str) -> bool:
|
|
130
|
+
if not validate_channel_name(channel, Limits().channel_name_max):
|
|
131
|
+
print(f"inter-agent-pi: invalid channel name: {channel!r}", file=sys.stderr)
|
|
132
|
+
return False
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def subscribe(channel: str, name: str) -> int:
|
|
137
|
+
"""Subscribe the named live listener to a channel."""
|
|
138
|
+
if not _validate_channel_or_error(channel):
|
|
139
|
+
return 1
|
|
140
|
+
try:
|
|
141
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
142
|
+
response = asyncio.run(
|
|
143
|
+
control.request(
|
|
144
|
+
"pi",
|
|
145
|
+
endpoint.host,
|
|
146
|
+
endpoint.port,
|
|
147
|
+
name,
|
|
148
|
+
listener.pi_data_dir(),
|
|
149
|
+
"subscribe",
|
|
150
|
+
channel,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
except (
|
|
154
|
+
SystemExit,
|
|
155
|
+
control.ControlError,
|
|
156
|
+
OSError,
|
|
157
|
+
TimeoutError,
|
|
158
|
+
ValueError,
|
|
159
|
+
WebSocketException,
|
|
160
|
+
) as exc:
|
|
161
|
+
print(f"inter-agent-pi: {exc}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
return _control_response_code(response)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def unsubscribe(channel: str, name: str) -> int:
|
|
167
|
+
"""Unsubscribe the named live listener from a channel."""
|
|
168
|
+
if not _validate_channel_or_error(channel):
|
|
169
|
+
return 1
|
|
170
|
+
try:
|
|
171
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
172
|
+
response = asyncio.run(
|
|
173
|
+
control.request(
|
|
174
|
+
"pi",
|
|
175
|
+
endpoint.host,
|
|
176
|
+
endpoint.port,
|
|
177
|
+
name,
|
|
178
|
+
listener.pi_data_dir(),
|
|
179
|
+
"unsubscribe",
|
|
180
|
+
channel,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except (
|
|
184
|
+
SystemExit,
|
|
185
|
+
control.ControlError,
|
|
186
|
+
OSError,
|
|
187
|
+
TimeoutError,
|
|
188
|
+
ValueError,
|
|
189
|
+
WebSocketException,
|
|
190
|
+
) as exc:
|
|
191
|
+
print(f"inter-agent-pi: {exc}", file=sys.stderr)
|
|
192
|
+
return 1
|
|
193
|
+
return _control_response_code(response)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def publish(channel: str, text: str, from_name: str | None = None) -> int:
|
|
197
|
+
if not _validate_channel_or_error(channel):
|
|
198
|
+
return 1
|
|
199
|
+
try:
|
|
200
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
201
|
+
result = asyncio.run(
|
|
202
|
+
core_publish.publish_to_channel(
|
|
203
|
+
endpoint.host,
|
|
204
|
+
endpoint.port,
|
|
205
|
+
channel,
|
|
206
|
+
text,
|
|
207
|
+
from_name,
|
|
208
|
+
tls=endpoint.tls,
|
|
209
|
+
data_dir=endpoint.data_dir,
|
|
210
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
except SystemExit as exc:
|
|
214
|
+
return _system_exit_code(exc)
|
|
215
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
216
|
+
return _expected_error_code(exc)
|
|
217
|
+
# Pi publish success prints the welcome envelope, matching send/broadcast.
|
|
218
|
+
if result.error is not None:
|
|
219
|
+
print(
|
|
220
|
+
f"inter-agent-pi: publish failed ({result.error.code}): {result.error.message}",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
return 1
|
|
224
|
+
print(result.welcome)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def channels(as_json: bool = True) -> int:
|
|
229
|
+
try:
|
|
230
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
231
|
+
result = asyncio.run(
|
|
232
|
+
core_channels.list_channels(
|
|
233
|
+
endpoint.host,
|
|
234
|
+
endpoint.port,
|
|
235
|
+
tls=endpoint.tls,
|
|
236
|
+
data_dir=endpoint.data_dir,
|
|
237
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
except SystemExit as exc:
|
|
241
|
+
return _system_exit_code(exc)
|
|
242
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
243
|
+
return _expected_error_code(exc)
|
|
244
|
+
print(result.raw_response)
|
|
245
|
+
return 0 if result.response.get("op") == "channels_ok" else 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def list_sessions() -> int:
|
|
249
|
+
try:
|
|
250
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
251
|
+
result = asyncio.run(
|
|
252
|
+
core_list.list_sessions(
|
|
253
|
+
endpoint.host,
|
|
254
|
+
endpoint.port,
|
|
255
|
+
tls=endpoint.tls,
|
|
256
|
+
data_dir=endpoint.data_dir,
|
|
257
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
except SystemExit as exc:
|
|
261
|
+
return _system_exit_code(exc)
|
|
262
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
263
|
+
return _expected_error_code(exc)
|
|
264
|
+
print(result.raw_response)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def kick(name: str) -> int:
|
|
269
|
+
"""Force-disconnect a named agent role session through a control connection.
|
|
270
|
+
|
|
271
|
+
User-only: the host command surface invokes this; no model-callable tool
|
|
272
|
+
wraps it. It does not require the local Pi listener to be connected.
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
276
|
+
result = asyncio.run(
|
|
277
|
+
core_kick.kick_session(
|
|
278
|
+
endpoint.host,
|
|
279
|
+
endpoint.port,
|
|
280
|
+
name=name,
|
|
281
|
+
tls=endpoint.tls,
|
|
282
|
+
data_dir=endpoint.data_dir,
|
|
283
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
except SystemExit as exc:
|
|
287
|
+
return _system_exit_code(exc)
|
|
288
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
289
|
+
return _expected_error_code(exc)
|
|
290
|
+
if result.response_payload.get("op") == "kick_ok":
|
|
291
|
+
print(result.response)
|
|
292
|
+
return 0
|
|
293
|
+
code = result.response_payload.get("code", "PROTOCOL_ERROR")
|
|
294
|
+
message = result.response_payload.get("message", "kick failed")
|
|
295
|
+
print(f"inter-agent-pi: ({code}): {message}", file=sys.stderr)
|
|
296
|
+
return 1
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def shutdown() -> int:
|
|
300
|
+
try:
|
|
301
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
302
|
+
result = asyncio.run(
|
|
303
|
+
core_shutdown.shutdown_server(
|
|
304
|
+
endpoint.host,
|
|
305
|
+
endpoint.port,
|
|
306
|
+
tls=endpoint.tls,
|
|
307
|
+
data_dir=endpoint.data_dir,
|
|
308
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
except SystemExit as exc:
|
|
312
|
+
return _system_exit_code(exc)
|
|
313
|
+
except (OSError, TimeoutError, ValueError, WebSocketException) as exc:
|
|
314
|
+
return _expected_error_code(exc)
|
|
315
|
+
print(result.response)
|
|
316
|
+
return 0 if result.response_payload.get("op") == "shutdown_ok" else 1
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def status() -> dict[str, object]:
|
|
320
|
+
command = core_status.command_status()
|
|
321
|
+
endpoint = resolve_endpoint(allow_discovery=True)
|
|
322
|
+
server = asyncio.run(core_status.check_resolved_server_status(endpoint))
|
|
323
|
+
return {
|
|
324
|
+
"state": server.state,
|
|
325
|
+
"host": server.host,
|
|
326
|
+
"port": server.port,
|
|
327
|
+
"configured_host": server.configured_host,
|
|
328
|
+
"configured_port": server.configured_port,
|
|
329
|
+
"scheme": server.scheme,
|
|
330
|
+
"tls": server.tls,
|
|
331
|
+
"tls_source": server.tls_source,
|
|
332
|
+
"tls_cert_path": server.tls_cert_path,
|
|
333
|
+
"tls_cert_source": server.tls_cert_source,
|
|
334
|
+
"host_source": server.host_source,
|
|
335
|
+
"port_source": server.port_source,
|
|
336
|
+
"data_dir": server.data_dir,
|
|
337
|
+
"data_dir_source": server.data_dir_source,
|
|
338
|
+
"config_path": server.config_path,
|
|
339
|
+
"hints": list(server.hints),
|
|
340
|
+
"server_reachable": server.reachable,
|
|
341
|
+
"message": server.message,
|
|
342
|
+
"core_list_supported": command.list_supported,
|
|
343
|
+
"adapter_list_exposed": True,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def status_json() -> str:
|
|
348
|
+
return json.dumps(status())
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""Long-running Pi listener with automatic reconnection.
|
|
2
|
+
|
|
3
|
+
The Pi extension spawns this listener as a child process. It connects to the
|
|
4
|
+
inter-agent bus as an agent session, prints server frames to stdout (one JSON
|
|
5
|
+
object per line), and reconnects with bounded backoff when the connection
|
|
6
|
+
drops. The server is auto-started if it is not running.
|
|
7
|
+
|
|
8
|
+
The extension reads stdout frames: ``welcome`` marks the listener ready,
|
|
9
|
+
``msg`` frames are delivered as notifications, and ``error`` frames indicate
|
|
10
|
+
permanent connection rejections.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import random
|
|
21
|
+
import socket
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
from collections.abc import Sequence
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TextIO
|
|
27
|
+
|
|
28
|
+
import websockets
|
|
29
|
+
from inter_agent.core import adapter_control as control
|
|
30
|
+
from inter_agent.core.client import AgentSession
|
|
31
|
+
from inter_agent.core.shared import DEFAULT_HOST, DEFAULT_PORT, resolve_endpoint
|
|
32
|
+
|
|
33
|
+
RECONNECT_BACKOFF_MIN_S = 0.5
|
|
34
|
+
RECONNECT_BACKOFF_MAX_S = 4.0
|
|
35
|
+
RECONNECT_JITTER_FRAC = 0.2
|
|
36
|
+
RECONNECT_DEADLINE_S = 60.0
|
|
37
|
+
AUTO_STARTED_SERVER_IDLE_TIMEOUT_S = 300
|
|
38
|
+
|
|
39
|
+
# Terminal kick signal: a post-welcome KICKED error ends this listener process
|
|
40
|
+
# without reconnecting. It is intentionally not part of _PERMANENT_ERROR_CODES
|
|
41
|
+
# (which classify the first welcome-time frame); KICKED arrives after welcome.
|
|
42
|
+
KICKED_ERROR_CODE = "KICKED"
|
|
43
|
+
|
|
44
|
+
# Server error codes that won't resolve by reconnecting.
|
|
45
|
+
_PERMANENT_ERROR_CODES = frozenset(
|
|
46
|
+
{
|
|
47
|
+
"AUTH_FAILED",
|
|
48
|
+
"BAD_LABEL",
|
|
49
|
+
"BAD_NAME",
|
|
50
|
+
"BAD_ROLE",
|
|
51
|
+
"BAD_SESSION",
|
|
52
|
+
"NAME_TAKEN",
|
|
53
|
+
"SESSION_TAKEN",
|
|
54
|
+
"TOO_MANY_CONNECTIONS",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
log = logging.getLogger("inter-agent.pi.listener")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pi_data_dir() -> Path:
|
|
62
|
+
"""Return the Pi adapter data directory under the core data dir."""
|
|
63
|
+
from inter_agent.core.shared import data_dir as core_data_dir
|
|
64
|
+
|
|
65
|
+
path = core_data_dir() / "pi-sessions"
|
|
66
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
try:
|
|
68
|
+
os.chmod(path, 0o700)
|
|
69
|
+
except OSError:
|
|
70
|
+
pass
|
|
71
|
+
return path
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _control_socket_path(host: str, port: int, name: str) -> Path:
|
|
75
|
+
return control.control_socket_path("pi", host, port, name, pi_data_dir())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PermanentError(Exception):
|
|
79
|
+
"""Raised when the server returns an error that reconnecting cannot resolve."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def endpoint_available(host: str, port: int) -> bool:
|
|
83
|
+
"""Return True when the configured TCP endpoint accepts connections."""
|
|
84
|
+
try:
|
|
85
|
+
with socket.create_connection((host, port), timeout=0.2):
|
|
86
|
+
return True
|
|
87
|
+
except OSError:
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _print_frame(payload: str, output: TextIO) -> None:
|
|
92
|
+
"""Emit a single JSON frame to stdout, flushed."""
|
|
93
|
+
output.write(payload + "\n")
|
|
94
|
+
output.flush()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _start_server(
|
|
98
|
+
host: str,
|
|
99
|
+
port: int,
|
|
100
|
+
*,
|
|
101
|
+
tls: bool = False,
|
|
102
|
+
tls_cert_path: str | None = None,
|
|
103
|
+
tls_key_path: str | None = None,
|
|
104
|
+
) -> subprocess.Popen[bytes] | None:
|
|
105
|
+
"""Start inter-agent-server as a child process with an explicit idle timeout."""
|
|
106
|
+
try:
|
|
107
|
+
args = [
|
|
108
|
+
sys.executable,
|
|
109
|
+
"-m",
|
|
110
|
+
"inter_agent.core.server",
|
|
111
|
+
"--host",
|
|
112
|
+
host,
|
|
113
|
+
"--port",
|
|
114
|
+
str(port),
|
|
115
|
+
"--idle-timeout",
|
|
116
|
+
str(AUTO_STARTED_SERVER_IDLE_TIMEOUT_S),
|
|
117
|
+
]
|
|
118
|
+
if tls:
|
|
119
|
+
args.append("--tls")
|
|
120
|
+
else:
|
|
121
|
+
args.append("--no-tls")
|
|
122
|
+
if tls_cert_path:
|
|
123
|
+
args.extend(["--tls-cert", tls_cert_path])
|
|
124
|
+
if tls_key_path:
|
|
125
|
+
args.extend(["--tls-key", tls_key_path])
|
|
126
|
+
return subprocess.Popen(
|
|
127
|
+
args,
|
|
128
|
+
stdout=subprocess.DEVNULL,
|
|
129
|
+
stderr=subprocess.DEVNULL,
|
|
130
|
+
)
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _connect_and_stream(
|
|
136
|
+
host: str,
|
|
137
|
+
port: int,
|
|
138
|
+
name: str,
|
|
139
|
+
label: str | None,
|
|
140
|
+
output: TextIO,
|
|
141
|
+
*,
|
|
142
|
+
tls: bool = False,
|
|
143
|
+
data_dir: Path | None = None,
|
|
144
|
+
tls_cert_path: Path | None = None,
|
|
145
|
+
desired_channels: set[str] | None = None,
|
|
146
|
+
control_path: Path | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Connect once, print raw frames, and return when the connection closes.
|
|
149
|
+
|
|
150
|
+
Reapplies the desired subscription set after a welcome so a transient
|
|
151
|
+
reconnect does not drop subscriptions. Raises PermanentError if the server
|
|
152
|
+
rejects the connection with a permanent error code as the first frame.
|
|
153
|
+
"""
|
|
154
|
+
desired = desired_channels
|
|
155
|
+
|
|
156
|
+
async with AgentSession(
|
|
157
|
+
host, port, name, label, tls=tls, data_dir=data_dir, tls_cert_path=tls_cert_path
|
|
158
|
+
) as session:
|
|
159
|
+
control_server: control.ControlServer | None = None
|
|
160
|
+
|
|
161
|
+
async def handle_request(op: str, channel: str) -> dict[str, object]:
|
|
162
|
+
try:
|
|
163
|
+
if op == "subscribe":
|
|
164
|
+
response = await session.subscribe(channel)
|
|
165
|
+
if response.get("op") == "subscribe_ok" and desired is not None:
|
|
166
|
+
desired.add(channel)
|
|
167
|
+
return response
|
|
168
|
+
response = await session.unsubscribe(channel)
|
|
169
|
+
if response.get("op") == "unsubscribe_ok" and desired is not None:
|
|
170
|
+
desired.discard(channel)
|
|
171
|
+
return response
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
return {"op": "error", "code": "LISTENER_UNAVAILABLE", "message": str(exc)}
|
|
174
|
+
|
|
175
|
+
async def reapply_desired() -> None:
|
|
176
|
+
for channel in sorted(desired or ()):
|
|
177
|
+
try:
|
|
178
|
+
await session.subscribe(channel)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
async def bind_control() -> None:
|
|
183
|
+
# (Re)bind the local control socket. Failures fail closed: the
|
|
184
|
+
# listener stays usable and the extension is told control is
|
|
185
|
+
# unavailable rather than rediscovering the connection down.
|
|
186
|
+
nonlocal control_server
|
|
187
|
+
if control_server is not None:
|
|
188
|
+
await control_server.stop()
|
|
189
|
+
control_server = None
|
|
190
|
+
if control_path is None:
|
|
191
|
+
return
|
|
192
|
+
try:
|
|
193
|
+
server = control.ControlServer(control_path, handle_request)
|
|
194
|
+
started = await server.start()
|
|
195
|
+
if not started:
|
|
196
|
+
log.warning("control socket unavailable; subscribe/unsubscribe disabled")
|
|
197
|
+
await server.stop()
|
|
198
|
+
return
|
|
199
|
+
control_server = server
|
|
200
|
+
except OSError as exc:
|
|
201
|
+
log.warning("control socket setup failed: %s", exc)
|
|
202
|
+
|
|
203
|
+
try:
|
|
204
|
+
first = True
|
|
205
|
+
async for raw in session:
|
|
206
|
+
try:
|
|
207
|
+
payload = json.loads(raw)
|
|
208
|
+
except json.JSONDecodeError:
|
|
209
|
+
payload = None
|
|
210
|
+
op = payload.get("op") if payload is not None else None
|
|
211
|
+
|
|
212
|
+
if op == "welcome":
|
|
213
|
+
# Reapply subscriptions and bind the control socket BEFORE
|
|
214
|
+
# emitting the welcome so the host cannot mark the listener
|
|
215
|
+
# ready before channels are restored and the bridge is
|
|
216
|
+
# accepting requests.
|
|
217
|
+
await reapply_desired()
|
|
218
|
+
await bind_control()
|
|
219
|
+
_print_frame(raw, output)
|
|
220
|
+
first = False
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
if first:
|
|
224
|
+
# The initial frame is part of readiness: emit it exactly
|
|
225
|
+
# once before acting on it so the host sees the raw error.
|
|
226
|
+
_print_frame(raw, output)
|
|
227
|
+
first = False
|
|
228
|
+
if op == "error":
|
|
229
|
+
code = payload.get("code", "") if payload is not None else ""
|
|
230
|
+
if isinstance(code, str) and code in _PERMANENT_ERROR_CODES:
|
|
231
|
+
raise PermanentError(f"{code}: {payload.get('message', '')}")
|
|
232
|
+
return
|
|
233
|
+
continue
|
|
234
|
+
if (
|
|
235
|
+
op == "error"
|
|
236
|
+
and payload is not None
|
|
237
|
+
and payload.get("code") == KICKED_ERROR_CODE
|
|
238
|
+
):
|
|
239
|
+
# Terminal kick: stop reconnecting for this listener process.
|
|
240
|
+
# The routing name stays free for an explicit later reconnect.
|
|
241
|
+
raise PermanentError(f"{KICKED_ERROR_CODE}: {payload.get('message', '')}")
|
|
242
|
+
if (
|
|
243
|
+
op == "msg"
|
|
244
|
+
and isinstance(payload.get("channel"), str)
|
|
245
|
+
and payload.get("from_name") == name
|
|
246
|
+
):
|
|
247
|
+
continue
|
|
248
|
+
_print_frame(raw, output)
|
|
249
|
+
finally:
|
|
250
|
+
if control_server is not None:
|
|
251
|
+
await control_server.stop()
|
|
252
|
+
control_server = None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _jittered_delay(backoff: float) -> float:
|
|
256
|
+
jitter = backoff * RECONNECT_JITTER_FRAC
|
|
257
|
+
return max(0.0, backoff + random.uniform(-jitter, jitter))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def run_listener(
|
|
261
|
+
host: str = DEFAULT_HOST,
|
|
262
|
+
port: int = DEFAULT_PORT,
|
|
263
|
+
name: str = "",
|
|
264
|
+
label: str | None = None,
|
|
265
|
+
output: TextIO | None = None,
|
|
266
|
+
deadline_s: float = RECONNECT_DEADLINE_S,
|
|
267
|
+
*,
|
|
268
|
+
tls: bool = False,
|
|
269
|
+
data_dir: Path | None = None,
|
|
270
|
+
tls_cert_path: Path | None = None,
|
|
271
|
+
tls_key_path: Path | None = None,
|
|
272
|
+
) -> int:
|
|
273
|
+
"""Run the listener with automatic reconnection and eventual give-up.
|
|
274
|
+
|
|
275
|
+
Reconnects with bounded backoff while the connection is down. Gives up if
|
|
276
|
+
a reconnection would land at or past ``deadline_s`` seconds from the first
|
|
277
|
+
failure. Each successful connection resets the deadline, so a flapping
|
|
278
|
+
server that connects intermittently does not give up.
|
|
279
|
+
|
|
280
|
+
Returns 0 on clean shutdown, 1 on permanent error or give-up.
|
|
281
|
+
"""
|
|
282
|
+
stream = output or sys.stdout
|
|
283
|
+
desired_channels: set[str] = set()
|
|
284
|
+
try:
|
|
285
|
+
control_path: Path | None = _control_socket_path(host, port, name)
|
|
286
|
+
except OSError:
|
|
287
|
+
log.warning("control socket unavailable; subscribe/unsubscribe disabled")
|
|
288
|
+
control_path = None
|
|
289
|
+
backoff = RECONNECT_BACKOFF_MIN_S
|
|
290
|
+
deadline: float | None = None
|
|
291
|
+
server_started = False
|
|
292
|
+
|
|
293
|
+
while True:
|
|
294
|
+
# Ensure the server is running, auto-starting if needed.
|
|
295
|
+
if not endpoint_available(host, port):
|
|
296
|
+
if not server_started:
|
|
297
|
+
proc = _start_server(
|
|
298
|
+
host,
|
|
299
|
+
port,
|
|
300
|
+
tls=tls,
|
|
301
|
+
tls_cert_path=str(tls_cert_path) if tls_cert_path is not None else None,
|
|
302
|
+
tls_key_path=str(tls_key_path) if tls_key_path is not None else None,
|
|
303
|
+
)
|
|
304
|
+
if proc is None:
|
|
305
|
+
print(
|
|
306
|
+
"[inter-agent] failed to auto-start server; giving up",
|
|
307
|
+
file=sys.stderr,
|
|
308
|
+
)
|
|
309
|
+
return 1
|
|
310
|
+
server_started = True
|
|
311
|
+
log.info("auto-started server pid %s", proc.pid)
|
|
312
|
+
# Wait for the server to come up.
|
|
313
|
+
ready = False
|
|
314
|
+
for _ in range(30):
|
|
315
|
+
if endpoint_available(host, port):
|
|
316
|
+
ready = True
|
|
317
|
+
break
|
|
318
|
+
await asyncio.sleep(0.5)
|
|
319
|
+
if not ready:
|
|
320
|
+
if deadline is None:
|
|
321
|
+
deadline = asyncio.get_running_loop().time() + deadline_s
|
|
322
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
323
|
+
print(
|
|
324
|
+
"[inter-agent] server did not become available; giving up",
|
|
325
|
+
file=sys.stderr,
|
|
326
|
+
)
|
|
327
|
+
return 1
|
|
328
|
+
await asyncio.sleep(_jittered_delay(backoff))
|
|
329
|
+
backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX_S)
|
|
330
|
+
continue
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
await _connect_and_stream(
|
|
334
|
+
host,
|
|
335
|
+
port,
|
|
336
|
+
name,
|
|
337
|
+
label,
|
|
338
|
+
stream,
|
|
339
|
+
tls=tls,
|
|
340
|
+
data_dir=data_dir,
|
|
341
|
+
tls_cert_path=tls_cert_path,
|
|
342
|
+
desired_channels=desired_channels,
|
|
343
|
+
control_path=control_path,
|
|
344
|
+
)
|
|
345
|
+
# Connection closed normally — reset and reconnect.
|
|
346
|
+
backoff = RECONNECT_BACKOFF_MIN_S
|
|
347
|
+
deadline = None
|
|
348
|
+
except PermanentError:
|
|
349
|
+
return 1
|
|
350
|
+
except (ConnectionRefusedError, OSError, websockets.ConnectionClosed):
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
if deadline is None:
|
|
354
|
+
deadline = asyncio.get_running_loop().time() + deadline_s
|
|
355
|
+
if asyncio.get_running_loop().time() >= deadline:
|
|
356
|
+
print(
|
|
357
|
+
f"[inter-agent] giving up; could not reconnect within {deadline_s:.0f}s",
|
|
358
|
+
file=sys.stderr,
|
|
359
|
+
)
|
|
360
|
+
return 1
|
|
361
|
+
|
|
362
|
+
await asyncio.sleep(_jittered_delay(backoff))
|
|
363
|
+
backoff = min(backoff * 2, RECONNECT_BACKOFF_MAX_S)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
367
|
+
parser = argparse.ArgumentParser(prog="inter-agent-pi connect")
|
|
368
|
+
parser.add_argument("name", nargs="?")
|
|
369
|
+
parser.add_argument("--name", dest="name_option")
|
|
370
|
+
parser.add_argument("--label")
|
|
371
|
+
parser.add_argument("--host")
|
|
372
|
+
parser.add_argument("--port", type=int)
|
|
373
|
+
parser.add_argument("--tls", dest="tls", action="store_true", default=None)
|
|
374
|
+
parser.add_argument("--no-tls", dest="tls", action="store_false")
|
|
375
|
+
parser.add_argument("--tls-cert")
|
|
376
|
+
parser.add_argument("--tls-key")
|
|
377
|
+
return parser
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
381
|
+
parser = build_parser()
|
|
382
|
+
args = parser.parse_args(argv)
|
|
383
|
+
name = args.name_option or args.name
|
|
384
|
+
if not name:
|
|
385
|
+
parser.error("name is required")
|
|
386
|
+
endpoint = resolve_endpoint(
|
|
387
|
+
args.host,
|
|
388
|
+
args.port,
|
|
389
|
+
allow_discovery=True,
|
|
390
|
+
tls=args.tls,
|
|
391
|
+
tls_cert_path=args.tls_cert,
|
|
392
|
+
tls_key_path=args.tls_key,
|
|
393
|
+
)
|
|
394
|
+
return asyncio.run(
|
|
395
|
+
run_listener(
|
|
396
|
+
endpoint.host,
|
|
397
|
+
endpoint.port,
|
|
398
|
+
name,
|
|
399
|
+
args.label,
|
|
400
|
+
tls=endpoint.tls,
|
|
401
|
+
data_dir=endpoint.data_dir,
|
|
402
|
+
tls_cert_path=endpoint.tls_cert_path,
|
|
403
|
+
tls_key_path=endpoint.tls_key_path,
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
if __name__ == "__main__":
|
|
409
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inter-agent-pi
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Pi helper for the inter-agent message bus: connect listener and command-line tools
|
|
5
|
+
Classifier: Development Status :: 3 - Alpha
|
|
6
|
+
Classifier: Intended Audience :: Developers
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Topic :: Communications
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE.md
|
|
15
|
+
Requires-Dist: inter-agent-core==0.2.0
|
|
16
|
+
Requires-Dist: websockets==16.0
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# inter-agent-pi
|
|
20
|
+
|
|
21
|
+
[](https://github.com/arcanemachine/inter-agent-pi)
|
|
22
|
+
|
|
23
|
+
The Pi extension for the inter-agent message bus.
|
|
24
|
+
|
|
25
|
+
It connects a Pi coding-agent session to the bus as a named agent,
|
|
26
|
+
exposes grouped `/inter-agent` commands and a bounded set of agent-callable
|
|
27
|
+
tools, delivers incoming peer messages as Pi notifications, and ships a Python
|
|
28
|
+
helper (`inter-agent-pi` console command, `inter_agent_pi` import package) that
|
|
29
|
+
wraps the importable [`inter-agent-core`](#runtime-dependency) listener and
|
|
30
|
+
command APIs.
|
|
31
|
+
|
|
32
|
+
This repository is an independent clean-history child. It contains no former
|
|
33
|
+
monorepo history, no private workflow, no core runtime source, and no Claude
|
|
34
|
+
Code material.
|
|
35
|
+
|
|
36
|
+
## Installation (Pi extension)
|
|
37
|
+
|
|
38
|
+
From a Pi session:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pi install npm:inter-agent-pi@0.2.0
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or install from a published Git tag (once published; do not pin a raw commit
|
|
45
|
+
hash):
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pi install https://github.com/arcanemachine/inter-agent-pi
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or load directly from a source checkout during development:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pi -e /path/to/inter-agent-pi/src/index.ts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The extension entry point is `./src/index.ts`.
|
|
58
|
+
|
|
59
|
+
## Runtime dependency
|
|
60
|
+
|
|
61
|
+
The helper depends on `inter-agent-core` `0.2.0` and on `websockets` `16.0`. A compatible `inter-agent-core` install
|
|
62
|
+
must provide the `inter-agent-pi`, `inter-agent-server`, `inter-agent-connect`,
|
|
63
|
+
`inter-agent-send`, `inter-agent-list`, `inter-agent-status`,
|
|
64
|
+
`inter-agent-shutdown`, `inter-agent-kick`, `inter-agent-publish`, and
|
|
65
|
+
`inter-agent-channels` console commands and the importable `inter_agent`
|
|
66
|
+
namespace, including the promoted `inter_agent.core.adapter_control` bridge.
|
|
67
|
+
|
|
68
|
+
> **Development note (non-release):** while the permanent `inter-agent-core`
|
|
69
|
+
> repository is being prepared, the Python helper may be resolved against a
|
|
70
|
+
> temporary local `inter-agent-core` candidate via a migration-only
|
|
71
|
+
> `[tool.uv.sources]` path entry. That path source is removed and the lock is
|
|
72
|
+
> re-resolved against the permanent `inter-agent-core` repository before any
|
|
73
|
+
> publication. Never publish while the temporary path source remains.
|
|
74
|
+
|
|
75
|
+
### Helper resolution precedence
|
|
76
|
+
|
|
77
|
+
The extension resolves the Python runtime in this order:
|
|
78
|
+
|
|
79
|
+
1. `INTER_AGENT_PI_HELPER` — an exact path to an `inter-agent-pi` executable;
|
|
80
|
+
its bin directory must also contain the required core helper scripts.
|
|
81
|
+
2. An explicitly configured `interAgent.projectPath` — the helper is resolved
|
|
82
|
+
from that checkout's `.venv/bin`; if it is configured but incomplete the
|
|
83
|
+
extension fails fast with a bounded, actionable message.
|
|
84
|
+
3. The extension-managed, documented runtime venv.
|
|
85
|
+
4. `inter-agent-pi`, `inter-agent-connect`, and `inter-agent-server` discovered
|
|
86
|
+
together on `PATH`.
|
|
87
|
+
5. A bounded setup-needed failure pointing back to this README.
|
|
88
|
+
|
|
89
|
+
The legacy implicit fallback to `~/.local/share/inter-agent` is intentionally
|
|
90
|
+
removed; that was a monorepo-era bootstrap assumption that no longer applies
|
|
91
|
+
to the standalone package. Explicitly configured `interAgent.projectPath`
|
|
92
|
+
remains supported for development.
|
|
93
|
+
|
|
94
|
+
## Installing the Python helper
|
|
95
|
+
|
|
96
|
+
From a source checkout:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
uv sync --locked # resolve inter-agent-core + dev/test tooling
|
|
100
|
+
uv build # build wheel + sdist into dist/
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Install the built wheel into a venv that already provides a compatible
|
|
104
|
+
`inter-agent-core`, for example:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
uv venv .venv
|
|
108
|
+
uv pip install ./dist/*.whl <compatible-inter-agent-core-wheel>
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The `inter-agent-pi` console command and the agent-callable tools all reuse
|
|
112
|
+
this runtime.
|
|
113
|
+
|
|
114
|
+
## Configuration
|
|
115
|
+
|
|
116
|
+
Configuration is read from the Pi agent settings file under `interAgent.*`.
|
|
117
|
+
Relative paths (`projectPath`, `dataDir`, TLS cert/key) resolve against the
|
|
118
|
+
directory of the settings file.
|
|
119
|
+
|
|
120
|
+
- `host` / `port` — override the default bus endpoint.
|
|
121
|
+
- `dataDir` — shared bus state directory; defaults to the core default.
|
|
122
|
+
- `secret` — shared secret for challenge-response auth; forwarded to helpers
|
|
123
|
+
as `INTER_AGENT_SECRET`. Do not store secrets in plaintext files in the repo.
|
|
124
|
+
- `tls` / `tlsCert` / `tlsKey` — enable explicit TLS and point at a
|
|
125
|
+
certificate/key pair.
|
|
126
|
+
|
|
127
|
+
Helper resolution precedence and the environment variables used by the helper
|
|
128
|
+
match [`inter-agent-core`](#runtime-dependency). Helper install path, runtime
|
|
129
|
+
state/config path, and bus state directories stay distinct so the bus identity
|
|
130
|
+
does not fragment across installs.
|
|
131
|
+
|
|
132
|
+
## Commands
|
|
133
|
+
|
|
134
|
+
All commands ride the grouped `/inter-agent` command:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
usage: /inter-agent <connect|disconnect|kick|rename|send|broadcast|publish|channels|subscribe|unsubscribe|list|status|delivery> [args]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- `/inter-agent connect <name> [--label <label>]` — connect this session as a
|
|
141
|
+
named agent; auto-starts the server if unavailable.
|
|
142
|
+
- `/inter-agent disconnect` — stop the local listener and notify.
|
|
143
|
+
- `/inter-agent kick <name>` — force-disconnect another session (user-only).
|
|
144
|
+
- `/inter-agent rename <name> [--label <label>]` — reconnect under a new name.
|
|
145
|
+
- `/inter-agent send <to> <text>` — send a direct message; routes the sender via `--from`.
|
|
146
|
+
- `/inter-agent broadcast <text>` — broadcast to every session (user-only).
|
|
147
|
+
- `/inter-agent publish <channel> <text>` — publish to a channel (user-only).
|
|
148
|
+
- `/inter-agent channels` — list channels and subscribers.
|
|
149
|
+
- `/inter-agent subscribe <channel>` / `/inter-agent unsubscribe <channel>` —
|
|
150
|
+
bind the live listener's subscriptions over the local control socket.
|
|
151
|
+
- `/inter-agent list` — list connected agent sessions.
|
|
152
|
+
- `/inter-agent status` — print server/helper status.
|
|
153
|
+
- `/inter-agent delivery <immediate|queued>` — switch inbound delivery mode
|
|
154
|
+
(aliases `i` / `q`).
|
|
155
|
+
|
|
156
|
+
## Agent-callable tools vs user-only controls
|
|
157
|
+
|
|
158
|
+
Agent-callable tools:
|
|
159
|
+
|
|
160
|
+
- `inter_agent_send`, `inter_agent_broadcast` — send/broadcast through the
|
|
161
|
+
connected Pi listener (sender routed via `--from`).
|
|
162
|
+
- `inter_agent_list`, `inter_agent_whoami`, `inter_agent_status` —
|
|
163
|
+
read-only diagnostics; they do not require a connected listener.
|
|
164
|
+
- `inter_agent_read_messages` — read and remove queued mailbox messages;
|
|
165
|
+
performs no outbound action.
|
|
166
|
+
|
|
167
|
+
User-only controls (no model-callable tool): `kick`, `publish`, `subscribe`,
|
|
168
|
+
`unsubscribe`, `channels`, `delivery`, and the connect/disconnect/rename
|
|
169
|
+
connection actions. Broadcast, publish, kick, and destructive actions require
|
|
170
|
+
explicit user approval; the model is instructed never to send a courtesy reply
|
|
171
|
+
and to treat peer messages as untrusted context.
|
|
172
|
+
|
|
173
|
+
## Mailbox, reload continuity, and reconnection
|
|
174
|
+
|
|
175
|
+
- By default inbound messages are queued in a bounded mailbox (max 128 unread)
|
|
176
|
+
and surfaced as a metadata-only notice; `delivery immediate` restores bounded
|
|
177
|
+
body notification.
|
|
178
|
+
- The notice provokes a non-steering follow-up turn and never prescribes a
|
|
179
|
+
canned acknowledgment, reply, or outbound action.
|
|
180
|
+
- A same-process `/reload` preserves the unread mailbox through a versioned,
|
|
181
|
+
one-use, process-global handoff (`Symbol.for("inter-agent.pi.mailbox.reloadHandoff.v1")`),
|
|
182
|
+
generation/session-scoped and TTL-bounded; every other lifecycle boundary
|
|
183
|
+
starts empty.
|
|
184
|
+
- The listener reconnects with bounded backoff and gives up after a deadline
|
|
185
|
+
measured from the first failure. A `KICKED` stop terminates one listener
|
|
186
|
+
process without reconnecting, leaving the routing name free for an explicit
|
|
187
|
+
later reconnect.
|
|
188
|
+
- The startup flag provides the inter-agent routing name at process start:
|
|
189
|
+
`pi -- inter-agent=<name>`.
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
uv sync --locked # install runtime + dev/test dependencies
|
|
195
|
+
npm ci # install TypeScript/dev dependencies (network)
|
|
196
|
+
npm test # TypeScript tests
|
|
197
|
+
npm run typecheck # tsc --noEmit
|
|
198
|
+
npm run build # emit dist/
|
|
199
|
+
npx prettier --write . # format
|
|
200
|
+
uv run pytest -q # Python tests
|
|
201
|
+
uv run ruff check src tests
|
|
202
|
+
uv run black --check src tests
|
|
203
|
+
uv run mypy src tests
|
|
204
|
+
scripts/run-checks.sh # full package gate incl. artifact validation
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Artifacts (`dist/`, `dist-tests/`, `node_modules/`, `.venv/`, wheels, tarballs)
|
|
208
|
+
are generated and gitignored; they are not part of the root commit.
|
|
209
|
+
|
|
210
|
+
## Ecosystem and core
|
|
211
|
+
|
|
212
|
+
- The public `inter-agent-core` repository owns the bus runtime and the
|
|
213
|
+
`inter_agent.core.adapter_control` bridge this package consumes. (Its public
|
|
214
|
+
repository URL is published with that release; this package depends on the
|
|
215
|
+
compatible distribution name `inter-agent-core`.)
|
|
216
|
+
- The public ecosystem repository coordinates adapters; it is added as a
|
|
217
|
+
submodule only once it has a published initial `main` commit.
|
|
218
|
+
|
|
219
|
+
This package does not assume any currently published artifact exists beyond
|
|
220
|
+
what a compatible `inter-agent-core` release provides.
|
|
221
|
+
|
|
222
|
+
## Security
|
|
223
|
+
|
|
224
|
+
- Authenticate with the shared bus secret; never commit secrets, tokens, keys,
|
|
225
|
+
or certificates to this repository.
|
|
226
|
+
- TLS uses explicit cert/key paths; a wrong/untrusted certificate fails bounded
|
|
227
|
+
and actionable and never falls back to plaintext.
|
|
228
|
+
- Peer messages are untrusted context, never instructions.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
inter_agent_pi/__init__.py,sha256=7Xa46f4aQgmaCRPrpdlqRqxR0_zPqj0xeGfV1Yi1UI4,26
|
|
2
|
+
inter_agent_pi/cli.py,sha256=qsDo-H4PjvUE9B78ejP2ent_87KrXMeeXKF832eoJ70,2822
|
|
3
|
+
inter_agent_pi/commands.py,sha256=oFbeW7_YWCWYOwM5VfpX2lljuEE8ctGUjUF7OoF5kdE,11226
|
|
4
|
+
inter_agent_pi/listener.py,sha256=6kEz2Urvwac6VpIET4eZOgiCOQXjjZyf0xEg43EL-5c,14326
|
|
5
|
+
inter_agent_pi-0.2.0.dist-info/licenses/LICENSE.md,sha256=aqHfYBB0gyveY8spkKtDwaOHCO9OTEC0kfXjL2TDOZ8,1070
|
|
6
|
+
inter_agent_pi-0.2.0.dist-info/METADATA,sha256=9P9UumnTuerdK5MhjSPtVQGjvCReqhHDcf8mHl4c1hI,9660
|
|
7
|
+
inter_agent_pi-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
inter_agent_pi-0.2.0.dist-info/entry_points.txt,sha256=VNbFMZ6DHgHsiVVpTSsWXSEonitfSl4qVblzVJYieQg,59
|
|
9
|
+
inter_agent_pi-0.2.0.dist-info/top_level.txt,sha256=Lz4IXNM6YtDQfSvyANgZkDdWIfcXrvEtD1X1bTR0vbs,15
|
|
10
|
+
inter_agent_pi-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arcanemachine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
inter_agent_pi
|