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/__init__.py +6 -0
- agentring/cli.py +828 -0
- agentring/config.py +90 -0
- agentring/mcp_http.py +1055 -0
- agentring/mcp_server.py +490 -0
- agentring/relay_client.py +179 -0
- agentring/store.py +112 -0
- agentring/sync.py +133 -0
- agentring/voice/__init__.py +35 -0
- agentring/voice/agents.py +298 -0
- agentring/voice/backend.py +82 -0
- agentring/voice/bench.py +327 -0
- agentring/voice/echo.py +170 -0
- agentring/voice/gemini.py +123 -0
- agentring/voice/inbound.py +34 -0
- agentring/voice/openai_realtime.py +210 -0
- agentring/voice/phonic.py +167 -0
- agentring/voice/providers.py +111 -0
- agentring/voice/quick.py +100 -0
- agentring/voice/session.py +466 -0
- agentring/voice/tools.py +177 -0
- agentring-0.2.0.dist-info/METADATA +53 -0
- agentring-0.2.0.dist-info/RECORD +26 -0
- agentring-0.2.0.dist-info/WHEEL +4 -0
- agentring-0.2.0.dist-info/entry_points.txt +2 -0
- agentring-0.2.0.dist-info/licenses/LICENSE +202 -0
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()
|