connectonion 0.6.3__py3-none-any.whl → 0.6.5__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.
- connectonion/__init__.py +1 -1
- connectonion/cli/co_ai/agent.py +3 -3
- connectonion/cli/co_ai/main.py +2 -2
- connectonion/cli/co_ai/plugins/__init__.py +2 -3
- connectonion/cli/co_ai/plugins/system_reminder.py +154 -0
- connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
- connectonion/cli/co_ai/prompts/system-reminders/agent.md +23 -0
- connectonion/cli/co_ai/prompts/system-reminders/plan_mode.md +13 -0
- connectonion/cli/co_ai/prompts/system-reminders/security.md +14 -0
- connectonion/cli/co_ai/prompts/system-reminders/simplicity.md +14 -0
- connectonion/cli/co_ai/tools/plan_mode.py +1 -4
- connectonion/cli/co_ai/tools/read.py +0 -6
- connectonion/cli/commands/copy_commands.py +21 -0
- connectonion/cli/commands/trust_commands.py +152 -0
- connectonion/cli/main.py +82 -0
- connectonion/core/llm.py +2 -2
- connectonion/docs/concepts/fast_rules.md +237 -0
- connectonion/docs/concepts/onboarding.md +465 -0
- connectonion/docs/concepts/plugins.md +2 -1
- connectonion/docs/concepts/trust.md +933 -192
- connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
- connectonion/docs/network/README.md +23 -1
- connectonion/docs/network/connect.md +135 -0
- connectonion/docs/network/host.md +73 -4
- connectonion/docs/useful_plugins/tool_approval.md +139 -0
- connectonion/network/__init__.py +7 -6
- connectonion/network/asgi/__init__.py +3 -0
- connectonion/network/asgi/http.py +125 -19
- connectonion/network/asgi/websocket.py +276 -15
- connectonion/network/connect.py +145 -29
- connectonion/network/host/auth.py +70 -67
- connectonion/network/host/routes.py +88 -3
- connectonion/network/host/server.py +100 -17
- connectonion/network/trust/__init__.py +27 -19
- connectonion/network/trust/factory.py +51 -24
- connectonion/network/trust/fast_rules.py +100 -0
- connectonion/network/trust/tools.py +316 -32
- connectonion/network/trust/trust_agent.py +403 -0
- connectonion/transcribe.py +1 -1
- connectonion/useful_plugins/__init__.py +2 -1
- connectonion/useful_plugins/tool_approval.py +233 -0
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/RECORD +45 -37
- connectonion/cli/co_ai/plugins/reminder.py +0 -76
- connectonion/cli/co_ai/plugins/shell_approval.py +0 -105
- connectonion/cli/co_ai/prompts/reminders/plan_mode.md +0 -34
- connectonion/cli/co_ai/reminders.py +0 -159
- connectonion/network/trust/prompts.py +0 -71
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
- {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/entry_points.txt +0 -0
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O
|
|
2
|
+
Purpose: WebSocket bidirectional communication for ASGI server with real-time agent I/O and keep-alive
|
|
3
3
|
LLM-Note:
|
|
4
4
|
Dependencies: imports from [network/io/websocket.py, network/asgi/http.py pydantic_json_encoder, asyncio, json, queue, threading] | imported by [network/asgi/__init__.py] | tested by [tests/network/test_asgi_websocket.py]
|
|
5
|
-
Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → agent sends events via io.log()/send() → forwards to client via websocket.send → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id
|
|
6
|
-
State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped)
|
|
7
|
-
Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), ASK_USER_RESPONSE (client), trace events (server)
|
|
8
|
-
Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed)
|
|
9
|
-
Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR
|
|
10
|
-
WebSocket handling for ASGI.
|
|
5
|
+
Data flow: handle_websocket() accepts connection → receives INPUT message with prompt+session → authenticates via route_handlers["auth"] → starts agent in background thread with WebSocketIO → sends PING every 30s for keep-alive → agent sends events via io.log()/send() → forwards to client via websocket.send → client responds with PONG to PING → client sends ASK_USER_RESPONSE for approvals → io receives via queue → agent resumes → returns OUTPUT with result+session_id → result saved to SessionStorage even if client disconnects
|
|
6
|
+
State/Effects: maintains WebSocket connection during agent execution | runs agent in daemon thread (non-blocking) | uses queue.Queue for thread-safe I/O between agent and WebSocket | no persistent state (connection-scoped) | results persisted to .co/session_results.jsonl for 24h TTL
|
|
7
|
+
Integration: exposes handle_websocket(scope, receive, send, route_handlers, storage, trust, blacklist, whitelist) | uses WebSocketIO for bidirectional I/O | supports session continuation (same as HTTP) | message types: INPUT (client), OUTPUT/ERROR (server), PING (server), PONG (client), ASK_USER_RESPONSE (client), trace events (server) | clients can poll GET /sessions/{id} to recover results after disconnect
|
|
8
|
+
Performance: async WebSocket handling | agent runs in separate thread to avoid blocking | queue-based message passing | streams events in real-time (thinking, tool_result, approval_needed) | PING task keeps connection alive through proxies/firewalls
|
|
9
|
+
Errors: sends ERROR message for invalid JSON, auth failures, missing prompt | closes connection with code 4004 for wrong path | catches exceptions in agent thread and sends ERROR | saves result to storage even on disconnect (enables polling recovery)
|
|
10
|
+
WebSocket handling for ASGI with keep-alive and session recovery.
|
|
11
11
|
|
|
12
12
|
ASGI Protocol Types (not our custom types - this is the ASGI spec):
|
|
13
13
|
- websocket.connect : ASGI sends when client wants to connect
|
|
@@ -18,10 +18,21 @@ ASGI Protocol Types (not our custom types - this is the ASGI spec):
|
|
|
18
18
|
- websocket.close : We send to close the connection
|
|
19
19
|
|
|
20
20
|
Our Application Types (sent inside websocket.send text payload):
|
|
21
|
-
- INPUT : Client sends prompt
|
|
22
|
-
- OUTPUT : Server sends final result
|
|
21
|
+
- INPUT : Client sends prompt with session_id
|
|
22
|
+
- OUTPUT : Server sends final result with session_id
|
|
23
23
|
- ERROR : Server sends error message
|
|
24
|
+
- PING : Server keep-alive (every 30s)
|
|
25
|
+
- PONG : Client acknowledges keep-alive
|
|
24
26
|
- ASK_USER_RESPONSE : Client responds to approval request
|
|
27
|
+
- ONBOARD_REQUIRED : Server sends when stranger needs to onboard
|
|
28
|
+
- ONBOARD_SUBMIT : Client sends invite_code or payment
|
|
29
|
+
- ONBOARD_SUCCESS : Server confirms onboard complete
|
|
30
|
+
- ADMIN_PROMOTE : Client (admin) requests promote
|
|
31
|
+
- ADMIN_DEMOTE : Client (admin) requests demote
|
|
32
|
+
- ADMIN_BLOCK : Client (admin) requests block
|
|
33
|
+
- ADMIN_UNBLOCK : Client (admin) requests unblock
|
|
34
|
+
- ADMIN_GET_LEVEL : Client (admin) requests client level
|
|
35
|
+
- ADMIN_RESULT : Server sends admin action result
|
|
25
36
|
- (trace events) : thinking, tool_result, approval_needed, etc.
|
|
26
37
|
"""
|
|
27
38
|
|
|
@@ -30,7 +41,10 @@ import json
|
|
|
30
41
|
import queue
|
|
31
42
|
import threading
|
|
32
43
|
|
|
44
|
+
from rich.console import Console
|
|
33
45
|
from ..io import WebSocketIO
|
|
46
|
+
|
|
47
|
+
console = Console()
|
|
34
48
|
# Import pydantic_json_encoder for serializing Pydantic models (e.g., TokenUsage) in WebSocket responses
|
|
35
49
|
from .http import pydantic_json_encoder
|
|
36
50
|
|
|
@@ -78,10 +92,37 @@ async def handle_websocket(
|
|
|
78
92
|
"text": json.dumps({"type": "ERROR", "message": "Invalid JSON"})})
|
|
79
93
|
continue
|
|
80
94
|
|
|
81
|
-
|
|
95
|
+
msg_type = data.get("type")
|
|
96
|
+
|
|
97
|
+
# Handle ONBOARD_SUBMIT (stranger submitting invite code or payment)
|
|
98
|
+
if msg_type == "ONBOARD_SUBMIT":
|
|
99
|
+
await _handle_onboard_submit(data, send, route_handlers)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
# Handle ADMIN messages (promote, demote, block, unblock, get_level)
|
|
103
|
+
if msg_type and msg_type.startswith("ADMIN_"):
|
|
104
|
+
await _handle_admin_message(data, send, route_handlers)
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
if msg_type == "INPUT": # Our app type: client wants to run agent
|
|
82
108
|
prompt, identity, sig_valid, err = route_handlers["auth"](
|
|
83
109
|
data, trust, blacklist=blacklist, whitelist=whitelist
|
|
84
110
|
)
|
|
111
|
+
|
|
112
|
+
# Check if stranger needs onboarding
|
|
113
|
+
if err and "forbidden" in err.lower():
|
|
114
|
+
# Get onboard requirements from trust config
|
|
115
|
+
trust_agent = route_handlers["trust_agent"]
|
|
116
|
+
onboard_info = _get_onboard_requirements(trust_agent)
|
|
117
|
+
if onboard_info:
|
|
118
|
+
await send({"type": "websocket.send",
|
|
119
|
+
"text": json.dumps({
|
|
120
|
+
"type": "ONBOARD_REQUIRED",
|
|
121
|
+
"identity": identity,
|
|
122
|
+
**onboard_info
|
|
123
|
+
})})
|
|
124
|
+
continue
|
|
125
|
+
|
|
85
126
|
if err:
|
|
86
127
|
await send({"type": "websocket.send",
|
|
87
128
|
"text": json.dumps({"type": "ERROR", "message": err})})
|
|
@@ -139,16 +180,23 @@ async def handle_websocket(
|
|
|
139
180
|
|
|
140
181
|
|
|
141
182
|
async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threading.Event) -> bool:
|
|
142
|
-
"""Pump messages between WebSocket and IO queues.
|
|
183
|
+
"""Pump messages between WebSocket and IO queues with keep-alive.
|
|
143
184
|
|
|
144
185
|
Runs until agent completes. Handles:
|
|
145
|
-
- Outgoing: io._outgoing queue -> WebSocket
|
|
146
|
-
- Incoming: WebSocket -> io._incoming queue (
|
|
186
|
+
- Outgoing: io._outgoing queue -> WebSocket (agent events)
|
|
187
|
+
- Incoming: WebSocket -> io._incoming queue (approval responses, PONG)
|
|
188
|
+
- Keep-alive: PING messages every 30s to detect dead connections
|
|
147
189
|
|
|
148
190
|
Returns:
|
|
149
191
|
True if client disconnected before agent completed, False otherwise.
|
|
150
192
|
When True, caller should skip sending final OUTPUT (client is gone,
|
|
151
|
-
but result is already saved to SessionStorage).
|
|
193
|
+
but result is already saved to SessionStorage for polling recovery).
|
|
194
|
+
|
|
195
|
+
Keep-alive mechanism:
|
|
196
|
+
- send_ping() task sends PING every 30s
|
|
197
|
+
- Client responds with PONG (handled in receive_incoming)
|
|
198
|
+
- Keeps connection alive through proxies/firewalls
|
|
199
|
+
- Client can detect dead connection if no PING for 60s
|
|
152
200
|
|
|
153
201
|
Implementation note:
|
|
154
202
|
Uses asyncio.Event for signaling disconnect between nested async functions.
|
|
@@ -191,7 +239,11 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
|
|
|
191
239
|
if msg["type"] == "websocket.receive":
|
|
192
240
|
try:
|
|
193
241
|
data = json.loads(msg.get("text", "{}"))
|
|
194
|
-
|
|
242
|
+
# Handle PONG responses (client responding to PING)
|
|
243
|
+
if data.get("type") == "PONG":
|
|
244
|
+
pass # Just acknowledge, no action needed
|
|
245
|
+
else:
|
|
246
|
+
io._incoming.put(data)
|
|
195
247
|
except json.JSONDecodeError:
|
|
196
248
|
pass
|
|
197
249
|
elif msg["type"] == "websocket.disconnect":
|
|
@@ -201,17 +253,226 @@ async def _pump_messages(ws_receive, ws_send, io: WebSocketIO, agent_done: threa
|
|
|
201
253
|
except asyncio.TimeoutError:
|
|
202
254
|
continue
|
|
203
255
|
|
|
256
|
+
async def send_ping():
|
|
257
|
+
"""Send PING messages every 30 seconds to keep connection alive."""
|
|
258
|
+
while not agent_done.is_set() and not disconnected.is_set():
|
|
259
|
+
await asyncio.sleep(30) # Send ping every 30 seconds
|
|
260
|
+
if not agent_done.is_set() and not disconnected.is_set():
|
|
261
|
+
try:
|
|
262
|
+
await ws_send({"type": "websocket.send", "text": json.dumps({"type": "PING"})})
|
|
263
|
+
except Exception:
|
|
264
|
+
# Connection likely closed, stop pinging
|
|
265
|
+
break
|
|
266
|
+
|
|
204
267
|
send_task = asyncio.create_task(send_outgoing())
|
|
205
268
|
recv_task = asyncio.create_task(receive_incoming())
|
|
269
|
+
ping_task = asyncio.create_task(send_ping())
|
|
206
270
|
|
|
207
271
|
while not agent_done.is_set():
|
|
208
272
|
await asyncio.sleep(0.05)
|
|
209
273
|
|
|
274
|
+
# Cancel all tasks
|
|
210
275
|
recv_task.cancel()
|
|
276
|
+
ping_task.cancel()
|
|
211
277
|
try:
|
|
212
278
|
await recv_task
|
|
213
279
|
except asyncio.CancelledError:
|
|
214
280
|
pass
|
|
281
|
+
try:
|
|
282
|
+
await ping_task
|
|
283
|
+
except asyncio.CancelledError:
|
|
284
|
+
pass
|
|
215
285
|
await send_task
|
|
216
286
|
|
|
217
287
|
return disconnected.is_set()
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _get_onboard_requirements(trust_agent) -> dict | None:
|
|
291
|
+
"""Get onboard requirements from trust config."""
|
|
292
|
+
config = trust_agent.config
|
|
293
|
+
onboard = config.get("onboard", {})
|
|
294
|
+
if not onboard:
|
|
295
|
+
return None
|
|
296
|
+
|
|
297
|
+
result = {"methods": []}
|
|
298
|
+
if "invite_code" in onboard:
|
|
299
|
+
result["methods"].append("invite_code")
|
|
300
|
+
if "payment" in onboard:
|
|
301
|
+
result["methods"].append("payment")
|
|
302
|
+
result["payment_amount"] = onboard["payment"]
|
|
303
|
+
# Include agent's address for payment transfer
|
|
304
|
+
payment_address = trust_agent.get_self_address()
|
|
305
|
+
if payment_address:
|
|
306
|
+
result["payment_address"] = payment_address
|
|
307
|
+
|
|
308
|
+
return result if result["methods"] else None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def _handle_onboard_submit(data: dict, send, route_handlers: dict):
|
|
312
|
+
"""Handle ONBOARD_SUBMIT message from client."""
|
|
313
|
+
trust_agent = route_handlers["trust_agent"]
|
|
314
|
+
|
|
315
|
+
# Authenticate the request (signature check only, open trust)
|
|
316
|
+
_, identity, sig_valid, err = route_handlers["auth"](data, "open")
|
|
317
|
+
if err or not sig_valid:
|
|
318
|
+
await send({"type": "websocket.send",
|
|
319
|
+
"text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
# Check if blocked BEFORE onboarding (don't waste invite codes/payments)
|
|
323
|
+
if trust_agent.is_blocked(identity):
|
|
324
|
+
await send({"type": "websocket.send",
|
|
325
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: blocked"})})
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
payload = data.get("payload", {})
|
|
329
|
+
invite_code = payload.get("invite_code")
|
|
330
|
+
payment = payload.get("payment", 0)
|
|
331
|
+
|
|
332
|
+
# Try invite code
|
|
333
|
+
if invite_code:
|
|
334
|
+
if trust_agent.verify_invite(identity, invite_code):
|
|
335
|
+
# Get actual level after promotion (may differ if already promoted)
|
|
336
|
+
actual_level = trust_agent.get_level(identity)
|
|
337
|
+
# Log to server console for auditing
|
|
338
|
+
console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with invite code [cyan]{invite_code}[/cyan] → {actual_level}")
|
|
339
|
+
await send({"type": "websocket.send",
|
|
340
|
+
"text": json.dumps({
|
|
341
|
+
"type": "ONBOARD_SUCCESS",
|
|
342
|
+
"identity": identity,
|
|
343
|
+
"level": actual_level,
|
|
344
|
+
"message": f"Invite code verified. You are now a {actual_level}."
|
|
345
|
+
})})
|
|
346
|
+
return
|
|
347
|
+
else:
|
|
348
|
+
console.print(f"[red]✗[/red] Invalid invite code [cyan]{invite_code}[/cyan] from {identity[:16]}...")
|
|
349
|
+
await send({"type": "websocket.send",
|
|
350
|
+
"text": json.dumps({"type": "ERROR", "message": "Invalid invite code"})})
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Try payment
|
|
354
|
+
if payment > 0:
|
|
355
|
+
if trust_agent.verify_payment(identity, payment):
|
|
356
|
+
# Get actual level after promotion
|
|
357
|
+
actual_level = trust_agent.get_level(identity)
|
|
358
|
+
# Log to server console for auditing
|
|
359
|
+
console.print(f"[green]✓[/green] Verified [bold]{identity[:16]}...[/bold] with payment [cyan]${payment}[/cyan] → {actual_level}")
|
|
360
|
+
await send({"type": "websocket.send",
|
|
361
|
+
"text": json.dumps({
|
|
362
|
+
"type": "ONBOARD_SUCCESS",
|
|
363
|
+
"identity": identity,
|
|
364
|
+
"level": actual_level,
|
|
365
|
+
"message": f"Payment verified. You are now a {actual_level}."
|
|
366
|
+
})})
|
|
367
|
+
return
|
|
368
|
+
else:
|
|
369
|
+
console.print(f"[red]✗[/red] Insufficient payment [cyan]${payment}[/cyan] from {identity[:16]}...")
|
|
370
|
+
await send({"type": "websocket.send",
|
|
371
|
+
"text": json.dumps({"type": "ERROR", "message": "Insufficient payment"})})
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
await send({"type": "websocket.send",
|
|
375
|
+
"text": json.dumps({"type": "ERROR", "message": "invite_code or payment required"})})
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def _handle_admin_message(data: dict, send, route_handlers: dict):
|
|
379
|
+
"""Handle ADMIN_* messages from client."""
|
|
380
|
+
trust_agent = route_handlers["trust_agent"]
|
|
381
|
+
msg_type = data.get("type")
|
|
382
|
+
|
|
383
|
+
# Authenticate the request
|
|
384
|
+
_, identity, sig_valid, err = route_handlers["auth"](data, "open")
|
|
385
|
+
if err or not sig_valid:
|
|
386
|
+
await send({"type": "websocket.send",
|
|
387
|
+
"text": json.dumps({"type": "ERROR", "message": err or "invalid signature"})})
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
# Check admin permission
|
|
391
|
+
if not trust_agent.is_admin(identity):
|
|
392
|
+
await send({"type": "websocket.send",
|
|
393
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: admin only"})})
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
payload = data.get("payload", {})
|
|
397
|
+
client_id = payload.get("client_id")
|
|
398
|
+
|
|
399
|
+
# Handle each admin action
|
|
400
|
+
if msg_type == "ADMIN_PROMOTE":
|
|
401
|
+
if not client_id:
|
|
402
|
+
await send({"type": "websocket.send",
|
|
403
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
404
|
+
return
|
|
405
|
+
result = route_handlers["admin_trust_promote"](client_id)
|
|
406
|
+
await send({"type": "websocket.send",
|
|
407
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "promote", **result})})
|
|
408
|
+
|
|
409
|
+
elif msg_type == "ADMIN_DEMOTE":
|
|
410
|
+
if not client_id:
|
|
411
|
+
await send({"type": "websocket.send",
|
|
412
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
413
|
+
return
|
|
414
|
+
result = route_handlers["admin_trust_demote"](client_id)
|
|
415
|
+
await send({"type": "websocket.send",
|
|
416
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "demote", **result})})
|
|
417
|
+
|
|
418
|
+
elif msg_type == "ADMIN_BLOCK":
|
|
419
|
+
if not client_id:
|
|
420
|
+
await send({"type": "websocket.send",
|
|
421
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
422
|
+
return
|
|
423
|
+
reason = payload.get("reason", "")
|
|
424
|
+
result = route_handlers["admin_trust_block"](client_id, reason)
|
|
425
|
+
await send({"type": "websocket.send",
|
|
426
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "block", **result})})
|
|
427
|
+
|
|
428
|
+
elif msg_type == "ADMIN_UNBLOCK":
|
|
429
|
+
if not client_id:
|
|
430
|
+
await send({"type": "websocket.send",
|
|
431
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
432
|
+
return
|
|
433
|
+
result = route_handlers["admin_trust_unblock"](client_id)
|
|
434
|
+
await send({"type": "websocket.send",
|
|
435
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "unblock", **result})})
|
|
436
|
+
|
|
437
|
+
elif msg_type == "ADMIN_GET_LEVEL":
|
|
438
|
+
if not client_id:
|
|
439
|
+
await send({"type": "websocket.send",
|
|
440
|
+
"text": json.dumps({"type": "ERROR", "message": "client_id required"})})
|
|
441
|
+
return
|
|
442
|
+
result = route_handlers["admin_trust_level"](client_id)
|
|
443
|
+
await send({"type": "websocket.send",
|
|
444
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "get_level", **result})})
|
|
445
|
+
|
|
446
|
+
elif msg_type == "ADMIN_ADD":
|
|
447
|
+
# Super admin only
|
|
448
|
+
if not trust_agent.is_super_admin(identity):
|
|
449
|
+
await send({"type": "websocket.send",
|
|
450
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
|
|
451
|
+
return
|
|
452
|
+
admin_id = payload.get("admin_id")
|
|
453
|
+
if not admin_id:
|
|
454
|
+
await send({"type": "websocket.send",
|
|
455
|
+
"text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
|
|
456
|
+
return
|
|
457
|
+
result = route_handlers["admin_admins_add"](admin_id)
|
|
458
|
+
await send({"type": "websocket.send",
|
|
459
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "add_admin", **result})})
|
|
460
|
+
|
|
461
|
+
elif msg_type == "ADMIN_REMOVE":
|
|
462
|
+
# Super admin only
|
|
463
|
+
if not trust_agent.is_super_admin(identity):
|
|
464
|
+
await send({"type": "websocket.send",
|
|
465
|
+
"text": json.dumps({"type": "ERROR", "message": "forbidden: super admin only"})})
|
|
466
|
+
return
|
|
467
|
+
admin_id = payload.get("admin_id")
|
|
468
|
+
if not admin_id:
|
|
469
|
+
await send({"type": "websocket.send",
|
|
470
|
+
"text": json.dumps({"type": "ERROR", "message": "admin_id required"})})
|
|
471
|
+
return
|
|
472
|
+
result = route_handlers["admin_admins_remove"](admin_id)
|
|
473
|
+
await send({"type": "websocket.send",
|
|
474
|
+
"text": json.dumps({"type": "ADMIN_RESULT", "action": "remove_admin", **result})})
|
|
475
|
+
|
|
476
|
+
else:
|
|
477
|
+
await send({"type": "websocket.send",
|
|
478
|
+
"text": json.dumps({"type": "ERROR", "message": f"Unknown admin action: {msg_type}"})})
|
connectonion/network/connect.py
CHANGED
|
@@ -6,14 +6,24 @@ Lifecycle (Client Side):
|
|
|
6
6
|
2. agent.input(prompt) opens WebSocket to relay /ws/input endpoint
|
|
7
7
|
3. Sends INPUT message: {type: "INPUT", input_id, to, prompt, session?}
|
|
8
8
|
4. Receives streaming events: tool_call, tool_result, thinking, assistant
|
|
9
|
-
5.
|
|
10
|
-
6.
|
|
9
|
+
5. If stranger: ONBOARD_REQUIRED → callback provides credentials → ONBOARD_SUBMIT → ONBOARD_SUCCESS → retry
|
|
10
|
+
6. Receives final OUTPUT: {type: "OUTPUT", result, session} or ask_user
|
|
11
|
+
7. Returns Response(text, done) - done=False means agent asked a question
|
|
11
12
|
|
|
12
13
|
Message Flow:
|
|
13
14
|
Client → /ws/input → Relay → forwards to Agent's /ws/announce connection
|
|
14
15
|
Agent processes → sends OUTPUT → Relay resolves pending_outputs future
|
|
15
16
|
Relay → forwards OUTPUT → Client receives response
|
|
16
17
|
|
|
18
|
+
Onboard Flow (for strangers connecting to careful/strict agents):
|
|
19
|
+
Client: INPUT
|
|
20
|
+
Server: ONBOARD_REQUIRED {methods: ["invite_code", "payment"], payment_amount: N}
|
|
21
|
+
Client: calls on_onboard callback to get credentials
|
|
22
|
+
Client: ONBOARD_SUBMIT {invite_code: "...", payment: N}
|
|
23
|
+
Server: ONBOARD_SUCCESS {level: "contact", message: "..."}
|
|
24
|
+
Client: INPUT (retry original prompt)
|
|
25
|
+
Server: OUTPUT
|
|
26
|
+
|
|
17
27
|
Related Files:
|
|
18
28
|
- oo-api/relay/routes.py: Relay server WebSocket endpoints
|
|
19
29
|
- connectonion/network/relay.py: Agent-side relay connection (serve_loop)
|
|
@@ -31,7 +41,7 @@ import json
|
|
|
31
41
|
import time
|
|
32
42
|
import uuid
|
|
33
43
|
from dataclasses import dataclass
|
|
34
|
-
from typing import Any, Dict, List, Optional
|
|
44
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
35
45
|
|
|
36
46
|
from .. import address as addr
|
|
37
47
|
|
|
@@ -96,7 +106,12 @@ class RemoteAgent:
|
|
|
96
106
|
"""
|
|
97
107
|
return self._ui_events
|
|
98
108
|
|
|
99
|
-
def input(
|
|
109
|
+
def input(
|
|
110
|
+
self,
|
|
111
|
+
prompt: str,
|
|
112
|
+
timeout: float = 60.0,
|
|
113
|
+
on_onboard: Optional[Callable[[List[str], Optional[float]], Dict[str, Any]]] = None
|
|
114
|
+
) -> Response:
|
|
100
115
|
"""
|
|
101
116
|
Send prompt to remote agent and get response.
|
|
102
117
|
|
|
@@ -107,6 +122,10 @@ class RemoteAgent:
|
|
|
107
122
|
Args:
|
|
108
123
|
prompt: Task/prompt to send
|
|
109
124
|
timeout: Seconds to wait for response (default 60)
|
|
125
|
+
on_onboard: Callback when agent requires onboarding (invite code or payment).
|
|
126
|
+
Called with (methods: list[str], payment_amount: float | None).
|
|
127
|
+
Should return {"invite_code": "..."} or {"payment": amount}.
|
|
128
|
+
If None, prompts interactively in terminal.
|
|
110
129
|
|
|
111
130
|
Returns:
|
|
112
131
|
Response with text and done flag
|
|
@@ -115,6 +134,12 @@ class RemoteAgent:
|
|
|
115
134
|
>>> response = agent.input("Book a flight to Tokyo")
|
|
116
135
|
>>> if not response.done:
|
|
117
136
|
... response = agent.input("March 15") # Answer the question
|
|
137
|
+
|
|
138
|
+
Onboard example:
|
|
139
|
+
>>> def handle_onboard(methods, payment_amount):
|
|
140
|
+
... if "invite_code" in methods:
|
|
141
|
+
... return {"invite_code": input("Enter invite code: ")}
|
|
142
|
+
>>> response = agent.input("Hello", on_onboard=handle_onboard)
|
|
118
143
|
"""
|
|
119
144
|
try:
|
|
120
145
|
asyncio.get_running_loop()
|
|
@@ -125,11 +150,16 @@ class RemoteAgent:
|
|
|
125
150
|
except RuntimeError as e:
|
|
126
151
|
if "input() cannot be used" in str(e):
|
|
127
152
|
raise
|
|
128
|
-
return asyncio.run(self._stream_input(prompt, timeout))
|
|
153
|
+
return asyncio.run(self._stream_input(prompt, timeout, on_onboard))
|
|
129
154
|
|
|
130
|
-
async def input_async(
|
|
155
|
+
async def input_async(
|
|
156
|
+
self,
|
|
157
|
+
prompt: str,
|
|
158
|
+
timeout: float = 60.0,
|
|
159
|
+
on_onboard: Optional[Callable[[List[str], Optional[float]], Dict[str, Any]]] = None
|
|
160
|
+
) -> Response:
|
|
131
161
|
"""Async version of input()."""
|
|
132
|
-
return await self._stream_input(prompt, timeout)
|
|
162
|
+
return await self._stream_input(prompt, timeout, on_onboard)
|
|
133
163
|
|
|
134
164
|
def reset(self) -> None:
|
|
135
165
|
"""Clear conversation and start fresh."""
|
|
@@ -137,7 +167,12 @@ class RemoteAgent:
|
|
|
137
167
|
self._ui_events = []
|
|
138
168
|
self._status = "idle"
|
|
139
169
|
|
|
140
|
-
async def _stream_input(
|
|
170
|
+
async def _stream_input(
|
|
171
|
+
self,
|
|
172
|
+
prompt: str,
|
|
173
|
+
timeout: float,
|
|
174
|
+
on_onboard: Optional[Callable[[List[str], Optional[float]], Dict[str, Any]]] = None
|
|
175
|
+
) -> Response:
|
|
141
176
|
"""Send prompt via WebSocket and stream events."""
|
|
142
177
|
import websockets
|
|
143
178
|
|
|
@@ -153,30 +188,10 @@ class RemoteAgent:
|
|
|
153
188
|
ws_url = f"{self._relay_url}/ws/input"
|
|
154
189
|
|
|
155
190
|
# Generate input_id for routing/response matching
|
|
156
|
-
import uuid
|
|
157
191
|
input_id = str(uuid.uuid4())
|
|
158
192
|
|
|
159
193
|
# Build the INPUT message
|
|
160
|
-
input_msg =
|
|
161
|
-
"type": "INPUT",
|
|
162
|
-
"input_id": input_id,
|
|
163
|
-
"prompt": prompt,
|
|
164
|
-
"to": self.address,
|
|
165
|
-
"timestamp": int(time.time())
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
# Add session for conversation continuation
|
|
169
|
-
if self._current_session:
|
|
170
|
-
input_msg["session"] = self._current_session
|
|
171
|
-
|
|
172
|
-
# Sign if keys provided
|
|
173
|
-
if self._keys:
|
|
174
|
-
payload = {"prompt": prompt, "to": self.address, "timestamp": input_msg["timestamp"]}
|
|
175
|
-
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
|
176
|
-
signature = addr.sign(self._keys, canonical.encode())
|
|
177
|
-
input_msg["payload"] = payload
|
|
178
|
-
input_msg["from"] = self._keys["address"]
|
|
179
|
-
input_msg["signature"] = signature.hex()
|
|
194
|
+
input_msg = self._build_input_message(prompt, input_id)
|
|
180
195
|
|
|
181
196
|
try:
|
|
182
197
|
async with websockets.connect(ws_url) as ws:
|
|
@@ -209,6 +224,43 @@ class RemoteAgent:
|
|
|
209
224
|
self._status = "idle"
|
|
210
225
|
raise ConnectionError(f"Agent error: {event.get('message', event.get('error'))}")
|
|
211
226
|
|
|
227
|
+
elif event_type == "ONBOARD_REQUIRED":
|
|
228
|
+
# Agent requires onboarding (invite code or payment)
|
|
229
|
+
methods = event.get("methods", [])
|
|
230
|
+
payment_amount = event.get("payment_amount")
|
|
231
|
+
|
|
232
|
+
# Add onboard_required event to UI
|
|
233
|
+
self._add_ui_event({
|
|
234
|
+
"type": "onboard_required",
|
|
235
|
+
"methods": methods,
|
|
236
|
+
"payment_amount": payment_amount
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
# Get credentials from callback or prompt interactively
|
|
240
|
+
if on_onboard:
|
|
241
|
+
credentials = on_onboard(methods, payment_amount)
|
|
242
|
+
else:
|
|
243
|
+
credentials = self._prompt_onboard(methods, payment_amount)
|
|
244
|
+
|
|
245
|
+
# Send ONBOARD_SUBMIT
|
|
246
|
+
submit_msg = self._build_onboard_submit(credentials)
|
|
247
|
+
await ws.send(json.dumps(submit_msg))
|
|
248
|
+
# Continue loop to wait for ONBOARD_SUCCESS
|
|
249
|
+
|
|
250
|
+
elif event_type == "ONBOARD_SUCCESS":
|
|
251
|
+
# Onboard successful - add to UI
|
|
252
|
+
self._add_ui_event({
|
|
253
|
+
"type": "onboard_success",
|
|
254
|
+
"level": event.get("level", "contact"),
|
|
255
|
+
"message": event.get("message", "Onboard successful")
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
# Retry the original prompt
|
|
259
|
+
retry_input_id = str(uuid.uuid4())
|
|
260
|
+
retry_msg = self._build_input_message(prompt, retry_input_id)
|
|
261
|
+
await ws.send(json.dumps(retry_msg))
|
|
262
|
+
# Continue loop to wait for OUTPUT
|
|
263
|
+
|
|
212
264
|
elif event_type == "ask_user":
|
|
213
265
|
# Agent is asking a question - return done=False so caller sends another input()
|
|
214
266
|
self._status = "waiting"
|
|
@@ -233,6 +285,70 @@ class RemoteAgent:
|
|
|
233
285
|
self._status = "idle"
|
|
234
286
|
raise TimeoutError(f"Request timed out after {timeout}s")
|
|
235
287
|
|
|
288
|
+
def _build_input_message(self, prompt: str, input_id: str) -> Dict[str, Any]:
|
|
289
|
+
"""Build INPUT message with optional signing."""
|
|
290
|
+
input_msg = {
|
|
291
|
+
"type": "INPUT",
|
|
292
|
+
"input_id": input_id,
|
|
293
|
+
"prompt": prompt,
|
|
294
|
+
"to": self.address,
|
|
295
|
+
"timestamp": int(time.time())
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
# Add session for conversation continuation
|
|
299
|
+
if self._current_session:
|
|
300
|
+
input_msg["session"] = self._current_session
|
|
301
|
+
|
|
302
|
+
# Sign if keys provided
|
|
303
|
+
if self._keys:
|
|
304
|
+
payload = {"prompt": prompt, "to": self.address, "timestamp": input_msg["timestamp"]}
|
|
305
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
|
306
|
+
signature = addr.sign(self._keys, canonical.encode())
|
|
307
|
+
input_msg["payload"] = payload
|
|
308
|
+
input_msg["from"] = self._keys["address"]
|
|
309
|
+
input_msg["signature"] = signature.hex()
|
|
310
|
+
|
|
311
|
+
return input_msg
|
|
312
|
+
|
|
313
|
+
def _build_onboard_submit(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
|
314
|
+
"""Build ONBOARD_SUBMIT message with optional signing."""
|
|
315
|
+
payload = {
|
|
316
|
+
"timestamp": int(time.time()),
|
|
317
|
+
**credentials
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
submit_msg: Dict[str, Any] = {
|
|
321
|
+
"type": "ONBOARD_SUBMIT",
|
|
322
|
+
"payload": payload
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Sign if keys provided
|
|
326
|
+
if self._keys:
|
|
327
|
+
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
|
328
|
+
signature = addr.sign(self._keys, canonical.encode())
|
|
329
|
+
submit_msg["from"] = self._keys["address"]
|
|
330
|
+
submit_msg["signature"] = signature.hex()
|
|
331
|
+
|
|
332
|
+
return submit_msg
|
|
333
|
+
|
|
334
|
+
def _prompt_onboard(self, methods: List[str], payment_amount: Optional[float]) -> Dict[str, Any]:
|
|
335
|
+
"""Prompt user interactively for onboard credentials."""
|
|
336
|
+
print(f"\n🔐 Access verification required")
|
|
337
|
+
print(f" Available methods: {', '.join(methods)}")
|
|
338
|
+
|
|
339
|
+
if "invite_code" in methods:
|
|
340
|
+
code = input(" Enter invite code: ").strip()
|
|
341
|
+
if code:
|
|
342
|
+
return {"invite_code": code}
|
|
343
|
+
|
|
344
|
+
if "payment" in methods and payment_amount:
|
|
345
|
+
print(f" Payment required: ${payment_amount}")
|
|
346
|
+
confirm = input(" Pay now? [y/N]: ").strip().lower()
|
|
347
|
+
if confirm == 'y':
|
|
348
|
+
return {"payment": payment_amount}
|
|
349
|
+
|
|
350
|
+
raise ValueError("No valid onboard credentials provided")
|
|
351
|
+
|
|
236
352
|
def _handle_stream_event(self, event: Dict[str, Any]) -> None:
|
|
237
353
|
"""Handle streaming event and update UI."""
|
|
238
354
|
event_type = event.get("type")
|