agentring 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.
agentring/cli.py ADDED
@@ -0,0 +1,828 @@
1
+ """agentring command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ import signal
10
+ import sys
11
+ import time
12
+ from datetime import datetime, timezone
13
+ from typing import Annotated, Any, Optional
14
+
15
+ import segno
16
+ import typer
17
+
18
+ from . import __version__
19
+ from .config import ConfigError, Settings, home_dir, load_settings, write_settings
20
+ from .relay_client import InsecureRelayError, RelayClient, RelayError
21
+ from .store import Store
22
+ from .sync import FatalSyncError, catch_up, run_ws
23
+
24
+ app = typer.Typer(
25
+ help="Let your AI agent call and message your phone.",
26
+ no_args_is_help=True,
27
+ add_completion=False,
28
+ pretty_exceptions_enable=False,
29
+ )
30
+ devices_app = typer.Typer(help="List or revoke paired phones.", no_args_is_help=False)
31
+ app.add_typer(devices_app, name="devices")
32
+ agents_app = typer.Typer(help="Your agents the voice AI can consult (agents.yaml).", no_args_is_help=False)
33
+ app.add_typer(agents_app, name="agents")
34
+ voice_app = typer.Typer(help="Voice AI tools.", no_args_is_help=True)
35
+ app.add_typer(voice_app, name="voice")
36
+
37
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable JSON output.")]
38
+ AiOpt = Annotated[bool, typer.Option("--ai", help="A voice AI talks on the call (default Gemini Live; see --provider).")]
39
+ ProviderOpt = Annotated[
40
+ Optional[str],
41
+ typer.Option("--provider", help="--ai: voice provider: gemini, openai, grok, phonic or local (default "
42
+ "AGENTRING_VOICE_PROVIDER, else gemini). Each needs its key: GEMINI_API_KEY, OPENAI_API_KEY, "
43
+ "XAI_API_KEY, PHONIC_API_KEY; local needs a server at AGENTRING_VOICE_URL (docs/voice.md)."),
44
+ ]
45
+ InstructionsOpt = Annotated[
46
+ Optional[str], typer.Option("--instructions", help="--ai: system prompt, or @file to read it from a file.")
47
+ ]
48
+ McpOpt = Annotated[Optional[list[str]], typer.Option("--mcp", help="--ai: MCP server URL whose tools the AI may use (repeatable).")]
49
+ ToolsOpt = Annotated[Optional[str], typer.Option("--tools", help="--ai: comma-separated allowlist of MCP tools.")]
50
+ ModelOpt = Annotated[Optional[str], typer.Option("--model", help="--ai: the provider's model (default AGENTRING_VOICE_MODEL, "
51
+ "else e.g. gemini-3.8-live, gpt-realtime-2.1).")]
52
+ AgentsFileOpt = Annotated[
53
+ Optional[str],
54
+ typer.Option("--agents", help="--ai: agents.yaml with your agents and MCP servers (default ~/.agentring/agents.yaml "
55
+ "if it exists; 'none' to skip it)."),
56
+ ]
57
+
58
+
59
+ def _agents_config(path: str | None, *, required: bool = False) -> Any:
60
+ from .voice.agents import AgentsConfigError, load_agents
61
+
62
+ if path == "none":
63
+ return None
64
+ file = path or str(home_dir() / "agents.yaml")
65
+ if path is None and not (home_dir() / "agents.yaml").exists():
66
+ if required:
67
+ raise _fail(f"no agents configured: create {file} (see docs/agents.md)")
68
+ return None
69
+ try:
70
+ return load_agents(file)
71
+ except AgentsConfigError as e:
72
+ raise _fail(str(e)) from None
73
+
74
+
75
+ async def _build_ai(instructions: str | None, mcp: list[str] | None, tools: str | None, model: str | None,
76
+ voice: str | None, agents_file: str | None = None, provider: str | None = None) -> Any:
77
+ try:
78
+ from .voice.quick import build_agent
79
+ except ImportError:
80
+ raise _fail("voice support is not installed: install agentring with the [voice] extra") from None
81
+ from .voice.providers import VoiceConfigError, resolve_voice
82
+
83
+ try:
84
+ choice = resolve_voice(provider, model, voice)
85
+ except VoiceConfigError as e:
86
+ raise _fail(f"--ai: {e}") from None
87
+ logging.getLogger("agentring").info("voice AI: %s (%s)", choice.provider.label, choice.model)
88
+ allow = [t.strip() for t in tools.split(",") if t.strip()] if tools else None
89
+ agents = _agents_config(agents_file)
90
+ try:
91
+ return await build_agent(choice, instructions=instructions, mcp=mcp, allow=allow, agents=agents)
92
+ except OSError as e:
93
+ raise _fail(f"cannot read the instructions: {e}") from None
94
+
95
+
96
+ def _print_result(r: Any) -> None:
97
+ for role, text in r.transcript:
98
+ typer.echo(f" {'you' if role == 'user' else 'agent'}: {text}")
99
+ for t in r.tools:
100
+ typer.echo(f" [tool] {t['name']} {json.dumps(t['args'], ensure_ascii=False)} → {t['output'][:120]}")
101
+ typer.echo(f"Call {r.end_reason}, {r.duration_s} s" if r.answered else f"Call {r.end_reason}")
102
+ if r.fallback_msg_id:
103
+ typer.echo(f"Sent the reason as a message instead ({r.fallback_msg_id})")
104
+
105
+
106
+ def _signals(stop: asyncio.Event) -> None:
107
+ loop = asyncio.get_running_loop()
108
+ for sig in (signal.SIGINT, signal.SIGTERM):
109
+ try:
110
+ loop.add_signal_handler(sig, stop.set)
111
+ except NotImplementedError: # Windows
112
+ pass
113
+
114
+
115
+ def _now_ms() -> int:
116
+ return int(time.time() * 1000)
117
+
118
+
119
+ def _fail(msg: str, code: int = 1) -> typer.Exit:
120
+ typer.secho(f"error: {msg}", fg=typer.colors.RED, err=True)
121
+ return typer.Exit(code)
122
+
123
+
124
+ def _settings() -> Settings:
125
+ try:
126
+ return load_settings()
127
+ except ConfigError as e:
128
+ raise _fail(str(e)) from None
129
+
130
+
131
+ def _client(s: Settings) -> RelayClient:
132
+ return RelayClient(s.relay_url, s.api_key)
133
+
134
+
135
+ def _emit_json(obj: Any) -> None:
136
+ typer.echo(json.dumps(obj, ensure_ascii=False, indent=2))
137
+
138
+
139
+ def _fmt_ts(ms: int) -> str:
140
+ return datetime.fromtimestamp(ms / 1000).strftime("%Y-%m-%d %H:%M:%S")
141
+
142
+
143
+ _DURATION = re.compile(r"^(\d+)\s*([smhd])$")
144
+
145
+
146
+ def parse_since(value: str, now_ms: int | None = None) -> int:
147
+ """Accepts a relative duration (30s, 10m, 2h, 1d), epoch milliseconds, or an ISO-8601 datetime."""
148
+ now_ms = now_ms if now_ms is not None else _now_ms()
149
+ v = value.strip()
150
+ m = _DURATION.match(v)
151
+ if m:
152
+ mult = {"s": 1, "m": 60, "h": 3600, "d": 86400}[m.group(2)]
153
+ return now_ms - int(m.group(1)) * mult * 1000
154
+ if v.isdigit():
155
+ return int(v)
156
+ try:
157
+ dt = datetime.fromisoformat(v.replace("Z", "+00:00"))
158
+ except ValueError:
159
+ raise typer.BadParameter("use e.g. 10m, 2h, 1d, epoch ms, or 2026-10-23T10:00:00") from None
160
+ if dt.tzinfo is None:
161
+ dt = dt.astimezone() # local time
162
+ return int(dt.timestamp() * 1000)
163
+
164
+
165
+ @app.callback()
166
+ def _root(
167
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Debug logging.")] = False,
168
+ ) -> None:
169
+ logging.basicConfig(
170
+ level=logging.DEBUG if verbose else logging.INFO,
171
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
172
+ stream=sys.stderr,
173
+ )
174
+ logging.getLogger("httpx").setLevel(logging.WARNING)
175
+ # At DEBUG websockets logs every handshake header, API keys included.
176
+ logging.getLogger("websockets").setLevel(logging.INFO)
177
+
178
+
179
+ @app.command()
180
+ def version() -> None:
181
+ """Print the version."""
182
+ typer.echo(__version__)
183
+
184
+
185
+ @app.command()
186
+ def init(
187
+ relay: Annotated[str, typer.Option("--relay", help="Relay base URL, e.g. https://relay.example.com")],
188
+ api_key: Annotated[str, typer.Option("--api-key", help="Relay API key (ak_...)")],
189
+ ) -> None:
190
+ """Configure the relay URL and API key (stored in ~/.agentring)."""
191
+ if not relay.startswith(("https://", "http://")):
192
+ raise _fail("--relay must be an http(s) URL")
193
+ if not api_key.startswith("ak_"):
194
+ raise _fail("--api-key must start with ak_")
195
+ try:
196
+ with RelayClient(relay, api_key) as c:
197
+ st = c.status()
198
+ except (RelayError, InsecureRelayError) as e:
199
+ raise _fail(str(e)) from None
200
+ if not isinstance(st, dict) or not st.get("relay_id"):
201
+ raise _fail(f"{relay} did not answer like an agentring relay; nothing was saved")
202
+ home = write_settings(relay, api_key)
203
+ typer.echo(f"Connected to relay {st['relay_id']} (agent name: {st.get('agent_name')}). Config saved in {home}")
204
+
205
+
206
+ @app.command()
207
+ def pair(
208
+ wait: Annotated[bool, typer.Option("--wait/--no-wait", help="Wait until the phone pairs.")] = True,
209
+ json_out: JsonOpt = False,
210
+ ) -> None:
211
+ """Show a QR code to pair the Android app (single use, valid 10 minutes)."""
212
+ s = _settings()
213
+ with _client(s) as c:
214
+ try:
215
+ known = {d["device_id"] for d in c.list_devices()}
216
+ tok = c.create_pairing_token()
217
+ except RelayError as e:
218
+ raise _fail(str(e)) from None
219
+
220
+ if json_out:
221
+ _emit_json(tok)
222
+ else:
223
+ qr = segno.make(tok["qr_payload"], error="m")
224
+ qr.terminal(compact=True, border=2)
225
+ typer.echo("\nScan this QR code with the agentring app (Pair → Scan QR).")
226
+ typer.echo("No camera? Copy this pairing code into the app (Pair → Paste code):\n")
227
+ typer.echo(f" {tok['qr_payload']}\n")
228
+ typer.echo(f"Valid until {_fmt_ts(tok['expires_at'])}, single use.")
229
+ if not wait:
230
+ return
231
+
232
+ if not json_out:
233
+ typer.echo("Waiting for the phone… (Ctrl+C to stop)")
234
+ try:
235
+ while _now_ms() < tok["expires_at"]:
236
+ time.sleep(2)
237
+ new = [d for d in c.list_devices() if d["device_id"] not in known]
238
+ if new:
239
+ d = new[0]
240
+ if json_out:
241
+ _emit_json({"paired": True, "device": d})
242
+ else:
243
+ typer.secho(f"Paired with {d['name']} ({d['device_id']}) ✓", fg=typer.colors.GREEN)
244
+ return
245
+ except KeyboardInterrupt:
246
+ raise typer.Exit(130) from None
247
+ except RelayError as e:
248
+ raise _fail(str(e)) from None
249
+ raise _fail("pairing token expired; run `agentring pair` again")
250
+
251
+
252
+ _TERMINAL = {"ended", "missed", "declined", "canceled"}
253
+
254
+
255
+ @app.command()
256
+ def call(
257
+ reason: Annotated[str, typer.Argument(help="Why you're calling; shown on the phone (max 500 chars).")],
258
+ urgent: Annotated[bool, typer.Option("--urgent", help="Ring like an alarm, bypassing Do Not Disturb if allowed.")] = False,
259
+ agent_name: Annotated[Optional[str], typer.Option("--agent-name", help="Caller name shown for this call.")] = None,
260
+ greeting: Annotated[
261
+ Optional[str], typer.Option("--greeting", help="What the agent says when the user answers (default: the reason).")
262
+ ] = None,
263
+ voice: Annotated[
264
+ Optional[str], typer.Option("--voice", help="With --ai the model's voice (e.g. Charon, marin); otherwise the macOS `say` voice.")
265
+ ] = None,
266
+ ai: AiOpt = False,
267
+ provider: ProviderOpt = None,
268
+ instructions: InstructionsOpt = None,
269
+ mcp: McpOpt = None,
270
+ tools: ToolsOpt = None,
271
+ model: ModelOpt = None,
272
+ agents_file: AgentsFileOpt = None,
273
+ fallback: Annotated[bool, typer.Option("--fallback/--no-fallback",
274
+ help="--ai: if nobody answers, send the reason as a message.")] = True,
275
+ json_out: JsonOpt = False,
276
+ ) -> None:
277
+ """Call the phone. Rings until answered (30 s). With --ai a voice AI talks to you; otherwise the agent greets and echoes you (audio test)."""
278
+ if ai:
279
+ return _ai_call(reason, urgent, agent_name, instructions, mcp, tools, model, voice, agents_file, fallback,
280
+ json_out, provider)
281
+ try:
282
+ from .voice.echo import EchoWorker, synthesize
283
+ except ImportError:
284
+ raise _fail("voice support is not installed: install agentring with the [voice] extra") from None
285
+
286
+ s = _settings()
287
+ log = logging.getLogger("agentring")
288
+ client = _client(s)
289
+
290
+ async def run() -> dict:
291
+ after = (await asyncio.to_thread(client.list_events, 0, 1))["last_seq"]
292
+ try:
293
+ created = await asyncio.to_thread(
294
+ client.create_call, reason, priority="urgent" if urgent else "normal", agent_name=agent_name
295
+ )
296
+ except RelayError as e:
297
+ raise _fail(str(e)) from None
298
+ c, join = created["call"], created["join"]
299
+ t0 = c["created_at"]
300
+ call_id = c["call_id"]
301
+ if not json_out:
302
+ typer.echo(f"Calling… {call_id} (Ctrl+C to hang up)")
303
+
304
+ text = greeting if greeting is not None else f"{c['agent_name']}. {reason}"
305
+ pcm = await asyncio.to_thread(synthesize, text, voice)
306
+ user_left = asyncio.Event()
307
+ worker = EchoWorker(pcm, user_left)
308
+ stop = asyncio.Event()
309
+ loop = asyncio.get_running_loop()
310
+ for sig in (signal.SIGINT, signal.SIGTERM):
311
+ try:
312
+ loop.add_signal_handler(sig, stop.set)
313
+ except NotImplementedError:
314
+ pass
315
+
316
+ def say(line: str) -> None:
317
+ if not json_out:
318
+ typer.echo(f" +{(_now_ms() - t0) / 1000:5.1f}s {line}")
319
+
320
+ # Join the room while the phone rings; the user shouldn't wait for us after answering.
321
+ joining = asyncio.create_task(worker.start(join["livekit_url"], join["livekit_token"]))
322
+ joined = False
323
+ try:
324
+ state = c["state"]
325
+ while state not in _TERMINAL:
326
+ if joining.done() and not joined:
327
+ joined = True
328
+ if joining.exception():
329
+ log.error("could not join the call's room: %s", joining.exception())
330
+ typer.secho("error: the agent could not join the call (LiveKit unreachable?)", fg=typer.colors.RED, err=True)
331
+ stop.set()
332
+ else:
333
+ say("agent in the room")
334
+ if user_left.is_set() and not stop.is_set():
335
+ # The room emptied. In a group call another phone may still answer
336
+ # while it rings: the relay decides when the call is over.
337
+ user_left.clear()
338
+ c = await asyncio.to_thread(client.get_call, call_id)
339
+ if c["state"] == "active":
340
+ say("everyone left; waiting while the other phones still ring")
341
+ state = c["state"]
342
+ continue
343
+ if stop.is_set():
344
+ c = await asyncio.to_thread(client.end_call, call_id)
345
+ state = c["state"]
346
+ break
347
+ page = await asyncio.to_thread(client.list_events, after, 100)
348
+ for e in page["events"]:
349
+ after = e.get("seq", after)
350
+ if e.get("call_id") != call_id:
351
+ continue
352
+ t = e["type"]
353
+ if t == "call.ringing":
354
+ say(f"ringing on {e['device_id']}")
355
+ elif t == "call.accept":
356
+ # Group calls: later phones join while the call is active.
357
+ verb = "joined" if state == "active" else "answered"
358
+ say(f"{verb} on {e.get('device_id', 'the phone')}")
359
+ state = "active"
360
+ elif t == "call.decline":
361
+ # Group calls: one phone declining doesn't end the call.
362
+ say(f"declined on {e.get('device_id', 'the phone')}")
363
+ state = (await asyncio.to_thread(client.get_call, call_id))["state"]
364
+ elif t == "call.missed":
365
+ say("no answer")
366
+ state = "missed"
367
+ elif t == "call.end":
368
+ say(f"ended by {e['by']} ({e['reason']})")
369
+ state = "ended"
370
+ try:
371
+ await asyncio.wait_for(asyncio.shield(stop.wait()), 0.3)
372
+ except asyncio.TimeoutError:
373
+ pass
374
+ finally:
375
+ if not joining.done():
376
+ joining.cancel()
377
+ await asyncio.gather(joining, return_exceptions=True)
378
+ await worker.stop()
379
+ return await asyncio.to_thread(client.get_call, call_id)
380
+
381
+ try:
382
+ final = asyncio.run(run())
383
+ finally:
384
+ client.close()
385
+ if json_out:
386
+ _emit_json(final)
387
+ else:
388
+ dur = f", {final['duration_ms'] / 1000:.0f} s" if final.get("duration_ms") is not None else ""
389
+ typer.echo(f"Call {final['state']}{dur}")
390
+ log.debug("call finished: %s", final)
391
+
392
+
393
+ @app.command()
394
+ def msg(
395
+ text: Annotated[str, typer.Argument(help="Message text (max 4000 chars).")],
396
+ reply_to: Annotated[Optional[str], typer.Option("--reply-to", help="msg_id or call_id this answers.")] = None,
397
+ json_out: JsonOpt = False,
398
+ ) -> None:
399
+ """Send a message to the phone. Prints the msg_id."""
400
+ s = _settings()
401
+ with _client(s) as c:
402
+ try:
403
+ m = c.post_message(text, reply_to=reply_to)
404
+ except RelayError as e:
405
+ raise _fail(str(e)) from None
406
+ store = Store(s.db_path)
407
+ store.upsert_message(m["msg_id"], "agent", m["text"], m["created_at"], m.get("reply_to"))
408
+ store.close()
409
+ if json_out:
410
+ _emit_json({"msg_id": m["msg_id"]})
411
+ else:
412
+ typer.echo(m["msg_id"])
413
+
414
+
415
+ @app.command()
416
+ def replies(
417
+ since: Annotated[
418
+ Optional[str], typer.Option("--since", help="Show all replies since: 10m, 2h, 1d, epoch ms or ISO date.")
419
+ ] = None,
420
+ all_: Annotated[bool, typer.Option("--all", help="Show all replies, not just new ones.")] = False,
421
+ peek: Annotated[bool, typer.Option("--peek", help="Don't mark the replies as seen.")] = False,
422
+ limit: Annotated[int, typer.Option("--limit", min=1, max=500)] = 100,
423
+ json_out: JsonOpt = False,
424
+ ) -> None:
425
+ """Show the user's replies. By default only new ones (not shown before), which are then marked as seen."""
426
+ s = _settings()
427
+ store = Store(s.db_path)
428
+ with _client(s) as c:
429
+ try:
430
+ catch_up(c, store)
431
+ except RelayError as e:
432
+ raise _fail(str(e)) from None
433
+
434
+ since_ms = parse_since(since) if since else None
435
+ undelivered_only = since is None and not all_
436
+ rows = store.replies(since=since_ms, undelivered_only=undelivered_only, limit=limit)
437
+ if rows and not peek:
438
+ store.mark_delivered([r["msg_id"] for r in rows], _now_ms())
439
+ for r in rows:
440
+ if r["read_at"] is None:
441
+ try:
442
+ c.mark_read(r["msg_id"])
443
+ except RelayError:
444
+ pass # best effort; the read receipt is cosmetic
445
+ store.close()
446
+
447
+ out = [
448
+ {"msg_id": r["msg_id"], "text": r["text"], "created_at": r["created_at"], "reply_to": r["reply_to"]}
449
+ for r in rows
450
+ ]
451
+ if json_out:
452
+ _emit_json({"replies": out})
453
+ return
454
+ if not out:
455
+ typer.echo("No new replies." if undelivered_only else "No replies.")
456
+ return
457
+ for r in out:
458
+ typer.echo(f"[{_fmt_ts(r['created_at'])}] {r['text']}")
459
+
460
+
461
+ @app.command()
462
+ def status(json_out: JsonOpt = False) -> None:
463
+ """Relay connection and paired devices."""
464
+ s = _settings()
465
+ with _client(s) as c:
466
+ try:
467
+ st = c.status()
468
+ devs = c.list_devices()
469
+ except RelayError as e:
470
+ if json_out:
471
+ _emit_json({"relay_url": s.relay_url, "reachable": False, "error": str(e)})
472
+ raise _fail(str(e)) from None
473
+ if json_out:
474
+ _emit_json({"relay_url": s.relay_url, "reachable": True, **st, "devices": devs})
475
+ return
476
+ typer.echo(f"Relay: {s.relay_url} ({st['relay_id']}, v{st['version']})")
477
+ typer.echo(f"Agent name: {st['agent_name']}")
478
+ typer.echo(f"Daemon: {'connected (serve running)' if st['agent_online'] else 'not connected'}")
479
+ typer.echo(f"Push: {st['push_provider']}")
480
+ if not devs:
481
+ typer.echo("Devices: none — run `agentring pair`")
482
+ for d in devs:
483
+ seen = _fmt_ts(d["last_seen_at"]) if d.get("last_seen_at") else "never"
484
+ push = "push ok" if d["has_push_token"] else "NO push token"
485
+ typer.echo(f"Device: {d['name']} ({d['device_id']}) — {push}, last seen {seen}")
486
+
487
+
488
+ @devices_app.callback(invoke_without_command=True)
489
+ def devices_list(
490
+ ctx: typer.Context,
491
+ include_revoked: Annotated[bool, typer.Option("--include-revoked")] = False,
492
+ json_out: JsonOpt = False,
493
+ ) -> None:
494
+ """List paired devices."""
495
+ if ctx.invoked_subcommand:
496
+ return
497
+ s = _settings()
498
+ with _client(s) as c:
499
+ try:
500
+ devs = c.list_devices(include_revoked=include_revoked)
501
+ except RelayError as e:
502
+ raise _fail(str(e)) from None
503
+ if json_out:
504
+ _emit_json({"devices": devs})
505
+ return
506
+ if not devs:
507
+ typer.echo("No devices paired.")
508
+ for d in devs:
509
+ state = f"revoked {_fmt_ts(d['revoked_at'])}" if d.get("revoked_at") else "active"
510
+ typer.echo(f"{d['device_id']} {d['name']:<24} paired {_fmt_ts(d['created_at'])} {state}")
511
+
512
+
513
+ def _ai_call(reason: str, urgent: bool, agent_name: str | None, instructions: str | None, mcp: list[str] | None,
514
+ tools: str | None, model: str | None, voice: str | None, agents_file: str | None, fallback: bool,
515
+ json_out: bool, provider: str | None = None) -> None:
516
+ from .voice import CallInfo
517
+
518
+ s = _settings()
519
+ client = _client(s)
520
+
521
+ async def run() -> Any:
522
+ built = await _build_ai(instructions, mcp, tools, model, voice, agents_file, provider)
523
+ agent = built.agent
524
+ try:
525
+ created = await asyncio.to_thread(
526
+ client.create_call, reason, priority="urgent" if urgent else "normal", agent_name=agent_name
527
+ )
528
+ except RelayError as e:
529
+ raise _fail(str(e)) from None
530
+ if not json_out:
531
+ typer.echo(f"Calling… {created['call']['call_id']} (Ctrl+C to hang up)")
532
+ stop = asyncio.Event()
533
+ _signals(stop)
534
+ try:
535
+ result = await agent.run(CallInfo(created["call"]["call_id"], reason), created["join"], relay=client, stop=stop)
536
+ if fallback and not result.answered and not stop.is_set():
537
+ from .voice.quick import missed_call_message
538
+
539
+ try:
540
+ result.fallback_msg_id = await asyncio.to_thread(missed_call_message, client, result.call_id, reason)
541
+ except RelayError as e:
542
+ logging.getLogger("agentring").warning("could not send the missed-call message: %s", e)
543
+ if built.consultations and built.consultations.pending and not stop.is_set():
544
+ if not json_out:
545
+ typer.echo("Waiting for your agents' answers; they will arrive as messages (Ctrl+C to stop)…")
546
+ stop_wait = asyncio.create_task(stop.wait())
547
+ drain = asyncio.create_task(built.consultations.drain())
548
+ await asyncio.wait({stop_wait, drain}, return_when=asyncio.FIRST_COMPLETED)
549
+ stop_wait.cancel()
550
+ drain.cancel()
551
+ return result
552
+ finally:
553
+ await built.aclose()
554
+
555
+ try:
556
+ result = asyncio.run(run())
557
+ finally:
558
+ client.close()
559
+ if json_out:
560
+ from dataclasses import asdict
561
+
562
+ _emit_json(asdict(result))
563
+ else:
564
+ _print_result(result)
565
+
566
+
567
+ @devices_app.command("revoke")
568
+ def devices_revoke(device_id: Annotated[str, typer.Argument(help="Device id (dev_...).")]) -> None:
569
+ """Revoke a device: its credentials stop working immediately."""
570
+ s = _settings()
571
+ with _client(s) as c:
572
+ try:
573
+ c.revoke_device(device_id)
574
+ except RelayError as e:
575
+ raise _fail(str(e)) from None
576
+ typer.echo(f"Revoked {device_id}")
577
+
578
+
579
+ @agents_app.callback(invoke_without_command=True)
580
+ def agents_list(
581
+ ctx: typer.Context,
582
+ agents_file: Annotated[Optional[str], typer.Option("--agents", help="agents.yaml (default ~/.agentring/agents.yaml).")] = None,
583
+ json_out: JsonOpt = False,
584
+ ) -> None:
585
+ """List the agents and MCP servers in agents.yaml (and check the file)."""
586
+ if ctx.invoked_subcommand is not None:
587
+ ctx.obj = agents_file # `agentring agents --agents X ask ...`
588
+ return
589
+ cfg = _agents_config(agents_file, required=True)
590
+ if json_out:
591
+ from dataclasses import asdict
592
+
593
+ _emit_json(asdict(cfg))
594
+ return
595
+ for a in cfg.agents:
596
+ where = f" (in {a.cwd})" if a.cwd else ""
597
+ typer.echo(f"{a.name}: {a.description}\n $ {' '.join(a.command)}{where}")
598
+ for m in cfg.mcp:
599
+ typer.echo(f"MCP {m.url}" + (f" tools: {', '.join(m.tools)}" if m.tools is not None else " (all tools)"))
600
+ if not cfg.agents and not cfg.mcp:
601
+ typer.echo("agents.yaml is empty.")
602
+
603
+
604
+ @agents_app.command("ask")
605
+ def agents_ask(
606
+ ctx: typer.Context,
607
+ name: Annotated[str, typer.Argument(help="The agent's name in agents.yaml.")],
608
+ task: Annotated[str, typer.Argument(help="What to ask it, as the voice AI would.")],
609
+ agents_file: Annotated[Optional[str], typer.Option("--agents", help="agents.yaml (default ~/.agentring/agents.yaml).")] = None,
610
+ ) -> None:
611
+ """Ask one agent something without a call: checks that consult_agent will work."""
612
+ cfg = _agents_config(agents_file or ctx.obj, required=True)
613
+ agent = next((a for a in cfg.agents if a.name == name), None)
614
+ if agent is None:
615
+ raise _fail(f"no agent {name!r}; agents: {', '.join(a.name for a in cfg.agents) or 'none'}")
616
+ try:
617
+ typer.echo(asyncio.run(agent.ask(task)))
618
+ except (OSError, RuntimeError, TimeoutError) as e:
619
+ raise _fail(str(e)) from None
620
+
621
+
622
+ @voice_app.command("bench")
623
+ def voice_bench(
624
+ provider: Annotated[Optional[str], typer.Option("--provider", help="gemini, openai, grok, phonic or local "
625
+ "(default AGENTRING_VOICE_PROVIDER, else gemini).")] = None,
626
+ model: Annotated[Optional[str], typer.Option("--model", help="The provider's model (default AGENTRING_VOICE_MODEL, "
627
+ "else the provider's default).")] = None,
628
+ voice: Annotated[Optional[str], typer.Option("--voice", help="The provider's voice.")] = None,
629
+ runs: Annotated[int, typer.Option("--runs", min=1, max=20, help="Sessions per prompt.")] = 3,
630
+ lang: Annotated[str, typer.Option("--lang", help="Language of the spoken prompts: en or es.")] = "en",
631
+ prompts: Annotated[Optional[str], typer.Option("--prompts", help="Directory with your own recordings "
632
+ "(chat.wav, time.wav, chain.wav, consult.wav; 16-bit PCM) "
633
+ "instead of the system's text-to-speech.")] = None,
634
+ out: Annotated[Optional[str], typer.Option("--out", help="Keep each session's audio (WAV) and results.jsonl here.")] = None,
635
+ instructions: Annotated[Optional[str], typer.Option("--instructions", help="System prompt instead of the one "
636
+ "`call --ai` uses, or @file.")] = None,
637
+ json_out: JsonOpt = False,
638
+ ) -> None:
639
+ """Time a voice provider on the agentring phone-call test: 4 spoken prompts, the same demo tools.
640
+
641
+ Reports, per prompt, the median time from the end of speech to the first word, how often the right tools were called, and how often it spoke before a tool answered. Uses the provider's real API (and its minutes): about 12 short sessions with --runs 3.
642
+ """
643
+ from pathlib import Path
644
+
645
+ try:
646
+ from .voice import bench
647
+ except ImportError:
648
+ raise _fail("voice support is not installed: install agentring with the [voice] extra") from None
649
+ from .voice.providers import VoiceConfigError, resolve_voice
650
+
651
+ if lang not in bench.PROMPTS:
652
+ raise _fail(f"--lang: one of {', '.join(bench.PROMPTS)}")
653
+ try:
654
+ choice = resolve_voice(provider, model, voice)
655
+ except VoiceConfigError as e:
656
+ raise _fail(str(e)) from None
657
+ out_dir = Path(out).expanduser() if out else None
658
+ if out_dir:
659
+ out_dir.mkdir(parents=True, exist_ok=True)
660
+ try:
661
+ files = bench.prompt_files(Path(prompts).expanduser()) if prompts else bench.speak_prompts(lang, bench.temp_dir())
662
+ except (OSError, RuntimeError, ValueError) as e:
663
+ raise _fail(str(e)) from None
664
+ where = f" at {choice.url}" if choice.url else ""
665
+ if not json_out:
666
+ typer.echo(f"{choice.provider.label} ({choice.model}){where}: {len(bench.SCENARIOS)} prompts x {runs}")
667
+
668
+ def progress(s: Any) -> None:
669
+ if json_out:
670
+ return
671
+ took = f"{s.first_voice_s:.2f} s" if s.first_voice_s is not None else "no voice"
672
+ tools = ", ".join(s.tools) or "no tools"
673
+ typer.echo(f" {s.scenario:<8} #{s.run + 1} {took:<9} {tools}{' ' + s.error if s.error else ''}")
674
+
675
+ from .voice.quick import read_instructions
676
+
677
+ try:
678
+ text = read_instructions(instructions) if instructions else None
679
+ except OSError as e:
680
+ raise _fail(f"cannot read the instructions: {e}") from None
681
+ sessions = asyncio.run(bench.run_bench(choice.factory(), files, runs=runs, out=out_dir, instructions=text,
682
+ on_session=progress))
683
+ summ = bench.summary(sessions)
684
+ if json_out:
685
+ typer.echo(json.dumps({"provider": choice.provider.name, "model": choice.model, "url": choice.url,
686
+ "runs": runs, "lang": lang, "summary": summ}, ensure_ascii=False, indent=2))
687
+ return
688
+ typer.echo("")
689
+ typer.echo(f"{'prompt':<9}{'first voice':>12}{'right tools':>13}{'spoke before tool':>19}")
690
+ for name, r in summ.items():
691
+ fv = f"{r['first_voice_s']:.2f} s" if r["first_voice_s"] is not None else "-"
692
+ typer.echo(f"{name:<9}{fv:>12}{r['tools_ok']:>13}{(r['spoke_before_tool'] or '-'):>19}")
693
+ if out_dir:
694
+ typer.echo(f"\nAudio and results.jsonl in {out_dir}")
695
+
696
+
697
+ @app.command()
698
+ def mcp(
699
+ agent_name: Annotated[Optional[str], typer.Option("--agent-name", help="Caller name the phone shows for this agent's calls.")] = None,
700
+ device: Annotated[Optional[list[str]], typer.Option("--device", help="Ring only this phone (device id); repeat for several.")] = None,
701
+ ) -> None:
702
+ """Run as an MCP server over stdio, for agents: `claude mcp add agentring -- agentring mcp`."""
703
+ from .mcp_server import run_stdio
704
+
705
+ run_stdio(_settings(), agent_name=agent_name, device_ids=device or None)
706
+
707
+
708
+ @app.command("mcp-http")
709
+ def mcp_http(
710
+ host: Annotated[Optional[str], typer.Option("--host", help="Address to bind (default AGENTRING_MCP_HOST, else 127.0.0.1).")] = None,
711
+ port: Annotated[Optional[int], typer.Option("--port", help="Port (default AGENTRING_MCP_PORT, else 8790).")] = None,
712
+ relay_url: Annotated[Optional[str], typer.Option("--relay-url", help="The relay every API key is used against "
713
+ "(default AGENTRING_RELAY_URL). With a hub (AGENTRING_HUB_ADMIN_URL), "
714
+ "the relay's internal address.")] = None,
715
+ data_dir: Annotated[Optional[str], typer.Option("--data-dir", help="Per-user state (default AGENTRING_MCP_DATA_DIR, "
716
+ "else ~/.agentring/mcp-http).")] = None,
717
+ max_calls: Annotated[Optional[int], typer.Option("--max-calls", help="Voice calls at once, all users together "
718
+ "(default AGENTRING_MCP_MAX_CALLS, else 4).")] = None,
719
+ max_body: Annotated[Optional[int], typer.Option("--max-body", help="Largest request body in bytes "
720
+ "(default AGENTRING_MCP_MAX_BODY, else 1 MiB).")] = None,
721
+ allow_origin: Annotated[Optional[list[str]], typer.Option("--allow-origin", help="Browser Origin allowed to use "
722
+ "/mcp, repeatable; '*' = any (default "
723
+ "AGENTRING_MCP_ALLOWED_ORIGINS, comma-separated; else none).")] = None,
724
+ ) -> None:
725
+ """Run as a remote MCP server (Streamable HTTP at /mcp) for many users; each sends its relay API key."""
726
+ from pathlib import Path
727
+
728
+ from .mcp_http import HttpConfig, run_http
729
+
730
+ try:
731
+ cfg = HttpConfig.from_env(host=host, port=port, relay_url=relay_url, data_dir=Path(data_dir) if data_dir else None,
732
+ max_calls=max_calls, max_body=max_body, allowed_origins=allow_origin or None)
733
+ except ConfigError as e:
734
+ raise _fail(str(e)) from None
735
+ run_http(cfg)
736
+
737
+
738
+ def _describe(event: dict[str, Any]) -> str | None:
739
+ t = event.get("type")
740
+ if t == "msg.new":
741
+ who = "you" if event["from"] == "agent" else "user"
742
+ return f"msg.new from {who}: {event['text']}"
743
+ if t == "msg.read":
744
+ return f"msg.read {event['msg_id']} by {event['by']}"
745
+ if t == "ws.hello":
746
+ return None
747
+ return t
748
+
749
+
750
+ @app.command()
751
+ def serve(
752
+ ai: Annotated[bool, typer.Option("--ai", help="Answer calls from the phone with a voice AI (see --provider).")] = False,
753
+ provider: ProviderOpt = None,
754
+ instructions: InstructionsOpt = None,
755
+ mcp: McpOpt = None,
756
+ tools: ToolsOpt = None,
757
+ model: ModelOpt = None,
758
+ voice: Annotated[Optional[str], typer.Option("--voice", help="--ai: the model's voice (e.g. Charon, marin).")] = None,
759
+ agents_file: AgentsFileOpt = None,
760
+ ) -> None:
761
+ """Run the daemon: keeps a live connection to the relay. With --ai, answers the phone's calls with a voice AI."""
762
+ s = _settings()
763
+ store = Store(s.db_path)
764
+ client = _client(s)
765
+ log = logging.getLogger("agentring")
766
+
767
+ calls: set[asyncio.Task] = set()
768
+ voice_agent: Any = None
769
+
770
+ async def answer(e: dict[str, Any], stop: asyncio.Event) -> None:
771
+ from .voice import accept_incoming
772
+
773
+ if calls - {asyncio.current_task()}:
774
+ log.info("already on a call; not answering %s", e["call_id"])
775
+ return
776
+ accepted = await asyncio.to_thread(accept_incoming, client, e)
777
+ if not accepted:
778
+ return
779
+ info, join = accepted
780
+ log.info("answering %s", info.call_id)
781
+ r = await voice_agent.run(info, join, relay=client, stop=stop)
782
+ log.info("call %s: %s, %d s, %d turns", info.call_id, r.end_reason, r.duration_s, len(r.transcript))
783
+
784
+ def on_event(e: dict[str, Any], stop: asyncio.Event | None = None) -> None:
785
+ d = _describe(e)
786
+ if d:
787
+ log.info(d)
788
+ if voice_agent is not None and stop is not None and e.get("type") == "call.invite":
789
+ from .voice import is_incoming_call
790
+
791
+ if is_incoming_call(e):
792
+ t = asyncio.get_running_loop().create_task(answer(e, stop))
793
+ calls.add(t)
794
+ t.add_done_callback(calls.discard)
795
+
796
+ async def main() -> None:
797
+ nonlocal voice_agent
798
+ stop = asyncio.Event()
799
+ _signals(stop)
800
+ built: Any = None
801
+ if ai:
802
+ built = await _build_ai(instructions, mcp, tools, model, voice, agents_file, provider)
803
+ voice_agent = built.agent
804
+ log.info("answering calls with %d tools", len(voice_agent.tools))
805
+ log.info("agentring %s serving; relay %s", __version__, s.relay_url)
806
+ try:
807
+ await run_ws(client, s.api_key, store, lambda e: on_event(e, stop), stop=stop)
808
+ await asyncio.gather(*calls, return_exceptions=True)
809
+ finally:
810
+ if built is not None:
811
+ await built.aclose()
812
+
813
+ try:
814
+ asyncio.run(main())
815
+ except FatalSyncError as e:
816
+ raise _fail(str(e)) from None
817
+ finally:
818
+ client.close()
819
+ store.close()
820
+ log.info("stopped")
821
+
822
+
823
+ def main() -> None:
824
+ app()
825
+
826
+
827
+ if __name__ == "__main__":
828
+ main()