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.
Files changed (50) hide show
  1. connectonion/__init__.py +1 -1
  2. connectonion/cli/co_ai/agent.py +3 -3
  3. connectonion/cli/co_ai/main.py +2 -2
  4. connectonion/cli/co_ai/plugins/__init__.py +2 -3
  5. connectonion/cli/co_ai/plugins/system_reminder.py +154 -0
  6. connectonion/cli/co_ai/prompts/connectonion/concepts/trust.md +166 -208
  7. connectonion/cli/co_ai/prompts/system-reminders/agent.md +23 -0
  8. connectonion/cli/co_ai/prompts/system-reminders/plan_mode.md +13 -0
  9. connectonion/cli/co_ai/prompts/system-reminders/security.md +14 -0
  10. connectonion/cli/co_ai/prompts/system-reminders/simplicity.md +14 -0
  11. connectonion/cli/co_ai/tools/plan_mode.py +1 -4
  12. connectonion/cli/co_ai/tools/read.py +0 -6
  13. connectonion/cli/commands/copy_commands.py +21 -0
  14. connectonion/cli/commands/trust_commands.py +152 -0
  15. connectonion/cli/main.py +82 -0
  16. connectonion/core/llm.py +2 -2
  17. connectonion/docs/concepts/fast_rules.md +237 -0
  18. connectonion/docs/concepts/onboarding.md +465 -0
  19. connectonion/docs/concepts/plugins.md +2 -1
  20. connectonion/docs/concepts/trust.md +933 -192
  21. connectonion/docs/design-decisions/023-trust-policy-system-design.md +323 -0
  22. connectonion/docs/network/README.md +23 -1
  23. connectonion/docs/network/connect.md +135 -0
  24. connectonion/docs/network/host.md +73 -4
  25. connectonion/docs/useful_plugins/tool_approval.md +139 -0
  26. connectonion/network/__init__.py +7 -6
  27. connectonion/network/asgi/__init__.py +3 -0
  28. connectonion/network/asgi/http.py +125 -19
  29. connectonion/network/asgi/websocket.py +276 -15
  30. connectonion/network/connect.py +145 -29
  31. connectonion/network/host/auth.py +70 -67
  32. connectonion/network/host/routes.py +88 -3
  33. connectonion/network/host/server.py +100 -17
  34. connectonion/network/trust/__init__.py +27 -19
  35. connectonion/network/trust/factory.py +51 -24
  36. connectonion/network/trust/fast_rules.py +100 -0
  37. connectonion/network/trust/tools.py +316 -32
  38. connectonion/network/trust/trust_agent.py +403 -0
  39. connectonion/transcribe.py +1 -1
  40. connectonion/useful_plugins/__init__.py +2 -1
  41. connectonion/useful_plugins/tool_approval.py +233 -0
  42. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/METADATA +1 -1
  43. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/RECORD +45 -37
  44. connectonion/cli/co_ai/plugins/reminder.py +0 -76
  45. connectonion/cli/co_ai/plugins/shell_approval.py +0 -105
  46. connectonion/cli/co_ai/prompts/reminders/plan_mode.md +0 -34
  47. connectonion/cli/co_ai/reminders.py +0 -159
  48. connectonion/network/trust/prompts.py +0 -71
  49. {connectonion-0.6.3.dist-info → connectonion-0.6.5.dist-info}/WHEEL +0 -0
  50. {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
- if data.get("type") == "INPUT": # Our app type: client wants to run agent
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 (for approval responses)
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
- io._incoming.put(data)
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}"})})
@@ -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. Receives final OUTPUT: {type: "OUTPUT", result, session} or ask_user
10
- 6. Returns Response(text, done) - done=False means agent asked a question
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(self, prompt: str, timeout: float = 60.0) -> Response:
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(self, prompt: str, timeout: float = 60.0) -> Response:
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(self, prompt: str, timeout: float) -> Response:
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")