piopiy-agent 1.0.0__tar.gz
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.
- piopiy_agent-1.0.0/PKG-INFO +130 -0
- piopiy_agent-1.0.0/README.md +119 -0
- piopiy_agent-1.0.0/pyproject.toml +32 -0
- piopiy_agent-1.0.0/setup.cfg +4 -0
- piopiy_agent-1.0.0/src/piopiy_agent/__init__.py +22 -0
- piopiy_agent-1.0.0/src/piopiy_agent/__main__.py +114 -0
- piopiy_agent-1.0.0/src/piopiy_agent/_pb/__init__.py +0 -0
- piopiy_agent-1.0.0/src/piopiy_agent/_pb/agent_pb2.py +71 -0
- piopiy_agent-1.0.0/src/piopiy_agent/_pb/agent_pb2_grpc.py +88 -0
- piopiy_agent-1.0.0/src/piopiy_agent/job.py +94 -0
- piopiy_agent-1.0.0/src/piopiy_agent/worker.py +295 -0
- piopiy_agent-1.0.0/src/piopiy_agent.egg-info/PKG-INFO +130 -0
- piopiy_agent-1.0.0/src/piopiy_agent.egg-info/SOURCES.txt +15 -0
- piopiy_agent-1.0.0/src/piopiy_agent.egg-info/dependency_links.txt +1 -0
- piopiy_agent-1.0.0/src/piopiy_agent.egg-info/requires.txt +2 -0
- piopiy_agent-1.0.0/src/piopiy_agent.egg-info/top_level.txt +2 -0
- piopiy_agent-1.0.0/src/telecmi_agent/__init__.py +13 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piopiy-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Connect a voice agent to the TeleCMI telephony platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://piopiy.com
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: grpcio>=1.60
|
|
10
|
+
Requires-Dist: protobuf>=4.25
|
|
11
|
+
|
|
12
|
+
# piopiy-agent
|
|
13
|
+
|
|
14
|
+
Connect a voice agent to the TeleCMI telephony platform.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install piopiy-agent
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No `protoc`. No `grpcio-tools`. No protobuf version clash. The stubs ship
|
|
21
|
+
pre-generated, built against an old runtime, so they load under whatever
|
|
22
|
+
protobuf your agent framework pins.
|
|
23
|
+
|
|
24
|
+
## Check your credentials
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export PIOPIY_AGENT_ID=agent_42
|
|
28
|
+
export PIOPIY_TOKEN=eyJhbGciOi...
|
|
29
|
+
|
|
30
|
+
python -m piopiy_agent
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A `.env` in the working directory is picked up too, if `python-dotenv` is
|
|
34
|
+
installed.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
OK registered in 41ms
|
|
38
|
+
accept_deadline_ms 400
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Do this before anything else. It separates "my credentials are wrong" from
|
|
42
|
+
"my agent code is wrong", which otherwise look identical.
|
|
43
|
+
|
|
44
|
+
## Take calls
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from piopiy_agent import PiopiyWorker
|
|
49
|
+
|
|
50
|
+
worker = PiopiyWorker(max_sessions=10)
|
|
51
|
+
|
|
52
|
+
@worker.on_job
|
|
53
|
+
async def handle(job):
|
|
54
|
+
# Everything needed to join is on the job. The token is pre-minted and
|
|
55
|
+
# scoped to this one room - you never hold LiveKit admin credentials.
|
|
56
|
+
await my_agent.join(job.livekit_url, job.access_token, job.room_name)
|
|
57
|
+
|
|
58
|
+
# ONLY once you are actually in the room. The caller is bridged in on
|
|
59
|
+
# the strength of this call.
|
|
60
|
+
await job.accept()
|
|
61
|
+
|
|
62
|
+
await my_agent.run() # returns when the call ends
|
|
63
|
+
|
|
64
|
+
asyncio.run(worker.run())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`job.call` carries `from_number`, `to_number`, `direction`, `call_uuid` and any
|
|
68
|
+
whitelisted SIP headers.
|
|
69
|
+
|
|
70
|
+
## The one rule
|
|
71
|
+
|
|
72
|
+
**Accept only after you are in the room.**
|
|
73
|
+
|
|
74
|
+
TeleCMI bridges the caller the moment you accept. Accept on *receiving* the
|
|
75
|
+
job and the caller arrives in an empty room and hears silence — which is the
|
|
76
|
+
single failure this whole design exists to prevent.
|
|
77
|
+
|
|
78
|
+
You have `job.deadline_ms` (400 by default) to join. `job.remaining_ms` tells
|
|
79
|
+
you what is left.
|
|
80
|
+
|
|
81
|
+
If you join late, `job.accept()` returns **False** and sends nothing: the call
|
|
82
|
+
has gone to another instance, and a late accept would put two agents on one
|
|
83
|
+
call. Leave the room when you see False.
|
|
84
|
+
|
|
85
|
+
## What the SDK handles so you do not have to
|
|
86
|
+
|
|
87
|
+
- **Registration and reconnects.** A TeleCMI restart ends your stream with
|
|
88
|
+
`CANCELLED`, not `UNAVAILABLE`. `CANCELLED` is non-retryable by gRPC
|
|
89
|
+
convention, so a client reconnecting only on `UNAVAILABLE` silently stays
|
|
90
|
+
down after every deploy. This reconnects on any end of stream, with backoff.
|
|
91
|
+
- **Serving late.** Reports `NOT_SERVING` until one call has actually been
|
|
92
|
+
accepted. A gRPC channel opens long before a Python process can reach a
|
|
93
|
+
media server, and a fresh instance reports zero active sessions — so it
|
|
94
|
+
looks like the *best* candidate exactly when it is least able to answer.
|
|
95
|
+
- **Capacity.** Stops accepting at `max_sessions` and rejects with
|
|
96
|
+
`AT_CAPACITY`. Be honest with this number: a worker that takes more than it
|
|
97
|
+
can serve produces dead air, where a fast rejection costs TeleCMI 2ms and it
|
|
98
|
+
moves on.
|
|
99
|
+
- **Heartbeats and status.**
|
|
100
|
+
- **A handler that returns without accepting** is rejected rather than left to
|
|
101
|
+
time out — the caller is listening to silence for every millisecond of it.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Constructor arguments, or environment:
|
|
106
|
+
|
|
107
|
+
| | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `TELECMI_AGENT_ID` | from the dashboard |
|
|
110
|
+
| `TELECMI_TOKEN` | from the dashboard |
|
|
111
|
+
| `PIOPIY_REGISTER` | default `grpc.piopiy.com:50051` |
|
|
112
|
+
| `PIOPIY_TLS` | `false` only against a local register |
|
|
113
|
+
| `TELECMI_INSTANCE_ID` | defaults to hostname-pid |
|
|
114
|
+
|
|
115
|
+
## Why the stubs are vendored
|
|
116
|
+
|
|
117
|
+
Generated protobuf code carries the version it was built with, and the runtime
|
|
118
|
+
refuses to load gencode **newer** than itself. `pip install grpcio-tools`
|
|
119
|
+
resolves protobuf 7.x, while agent frameworks commonly pin 5.x — so generating
|
|
120
|
+
locally produces stubs your own environment cannot import:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
VersionError: gencode 7.35.1 runtime 5.29.6
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The error names protobuf, not gRPC and not TeleCMI, which is what makes it
|
|
127
|
+
expensive to diagnose. Shipping stubs built against an old runtime removes the
|
|
128
|
+
problem: protobuf accepts a runtime newer than the gencode, never older.
|
|
129
|
+
|
|
130
|
+
Verified against protobuf 4.25, 5.29 and 7.36.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# piopiy-agent
|
|
2
|
+
|
|
3
|
+
Connect a voice agent to the TeleCMI telephony platform.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install piopiy-agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
No `protoc`. No `grpcio-tools`. No protobuf version clash. The stubs ship
|
|
10
|
+
pre-generated, built against an old runtime, so they load under whatever
|
|
11
|
+
protobuf your agent framework pins.
|
|
12
|
+
|
|
13
|
+
## Check your credentials
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
export PIOPIY_AGENT_ID=agent_42
|
|
17
|
+
export PIOPIY_TOKEN=eyJhbGciOi...
|
|
18
|
+
|
|
19
|
+
python -m piopiy_agent
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
A `.env` in the working directory is picked up too, if `python-dotenv` is
|
|
23
|
+
installed.
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
OK registered in 41ms
|
|
27
|
+
accept_deadline_ms 400
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Do this before anything else. It separates "my credentials are wrong" from
|
|
31
|
+
"my agent code is wrong", which otherwise look identical.
|
|
32
|
+
|
|
33
|
+
## Take calls
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from piopiy_agent import PiopiyWorker
|
|
38
|
+
|
|
39
|
+
worker = PiopiyWorker(max_sessions=10)
|
|
40
|
+
|
|
41
|
+
@worker.on_job
|
|
42
|
+
async def handle(job):
|
|
43
|
+
# Everything needed to join is on the job. The token is pre-minted and
|
|
44
|
+
# scoped to this one room - you never hold LiveKit admin credentials.
|
|
45
|
+
await my_agent.join(job.livekit_url, job.access_token, job.room_name)
|
|
46
|
+
|
|
47
|
+
# ONLY once you are actually in the room. The caller is bridged in on
|
|
48
|
+
# the strength of this call.
|
|
49
|
+
await job.accept()
|
|
50
|
+
|
|
51
|
+
await my_agent.run() # returns when the call ends
|
|
52
|
+
|
|
53
|
+
asyncio.run(worker.run())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`job.call` carries `from_number`, `to_number`, `direction`, `call_uuid` and any
|
|
57
|
+
whitelisted SIP headers.
|
|
58
|
+
|
|
59
|
+
## The one rule
|
|
60
|
+
|
|
61
|
+
**Accept only after you are in the room.**
|
|
62
|
+
|
|
63
|
+
TeleCMI bridges the caller the moment you accept. Accept on *receiving* the
|
|
64
|
+
job and the caller arrives in an empty room and hears silence — which is the
|
|
65
|
+
single failure this whole design exists to prevent.
|
|
66
|
+
|
|
67
|
+
You have `job.deadline_ms` (400 by default) to join. `job.remaining_ms` tells
|
|
68
|
+
you what is left.
|
|
69
|
+
|
|
70
|
+
If you join late, `job.accept()` returns **False** and sends nothing: the call
|
|
71
|
+
has gone to another instance, and a late accept would put two agents on one
|
|
72
|
+
call. Leave the room when you see False.
|
|
73
|
+
|
|
74
|
+
## What the SDK handles so you do not have to
|
|
75
|
+
|
|
76
|
+
- **Registration and reconnects.** A TeleCMI restart ends your stream with
|
|
77
|
+
`CANCELLED`, not `UNAVAILABLE`. `CANCELLED` is non-retryable by gRPC
|
|
78
|
+
convention, so a client reconnecting only on `UNAVAILABLE` silently stays
|
|
79
|
+
down after every deploy. This reconnects on any end of stream, with backoff.
|
|
80
|
+
- **Serving late.** Reports `NOT_SERVING` until one call has actually been
|
|
81
|
+
accepted. A gRPC channel opens long before a Python process can reach a
|
|
82
|
+
media server, and a fresh instance reports zero active sessions — so it
|
|
83
|
+
looks like the *best* candidate exactly when it is least able to answer.
|
|
84
|
+
- **Capacity.** Stops accepting at `max_sessions` and rejects with
|
|
85
|
+
`AT_CAPACITY`. Be honest with this number: a worker that takes more than it
|
|
86
|
+
can serve produces dead air, where a fast rejection costs TeleCMI 2ms and it
|
|
87
|
+
moves on.
|
|
88
|
+
- **Heartbeats and status.**
|
|
89
|
+
- **A handler that returns without accepting** is rejected rather than left to
|
|
90
|
+
time out — the caller is listening to silence for every millisecond of it.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
Constructor arguments, or environment:
|
|
95
|
+
|
|
96
|
+
| | |
|
|
97
|
+
|---|---|
|
|
98
|
+
| `TELECMI_AGENT_ID` | from the dashboard |
|
|
99
|
+
| `TELECMI_TOKEN` | from the dashboard |
|
|
100
|
+
| `PIOPIY_REGISTER` | default `grpc.piopiy.com:50051` |
|
|
101
|
+
| `PIOPIY_TLS` | `false` only against a local register |
|
|
102
|
+
| `TELECMI_INSTANCE_ID` | defaults to hostname-pid |
|
|
103
|
+
|
|
104
|
+
## Why the stubs are vendored
|
|
105
|
+
|
|
106
|
+
Generated protobuf code carries the version it was built with, and the runtime
|
|
107
|
+
refuses to load gencode **newer** than itself. `pip install grpcio-tools`
|
|
108
|
+
resolves protobuf 7.x, while agent frameworks commonly pin 5.x — so generating
|
|
109
|
+
locally produces stubs your own environment cannot import:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
VersionError: gencode 7.35.1 runtime 5.29.6
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
The error names protobuf, not gRPC and not TeleCMI, which is what makes it
|
|
116
|
+
expensive to diagnose. Shipping stubs built against an old runtime removes the
|
|
117
|
+
problem: protobuf accepts a runtime newer than the gencode, never older.
|
|
118
|
+
|
|
119
|
+
Verified against protobuf 4.25, 5.29 and 7.36.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "piopiy-agent"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Connect a voice agent to the TeleCMI telephony platform"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
|
|
13
|
+
# grpcio only. The protobuf stubs are PRE-GENERATED and shipped in this
|
|
14
|
+
# package, so nobody installs grpcio-tools and nobody hits the gencode/runtime
|
|
15
|
+
# version clash that comes with generating them locally.
|
|
16
|
+
#
|
|
17
|
+
# protobuf is left unpinned on purpose: the stubs are built against the oldest
|
|
18
|
+
# runtime we support, and protobuf accepts a runtime NEWER than the gencode.
|
|
19
|
+
# Pinning it here would fight whatever the customer's agent framework pins.
|
|
20
|
+
dependencies = [
|
|
21
|
+
"grpcio>=1.60",
|
|
22
|
+
"protobuf>=4.25",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://piopiy.com"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.package-data]
|
|
32
|
+
piopiy_agent = ["_pb/*.py"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Connect a voice agent to the TeleCMI telephony platform.
|
|
2
|
+
|
|
3
|
+
from piopiy_agent import PiopiyWorker
|
|
4
|
+
|
|
5
|
+
worker = PiopiyWorker(agent_id="agent_42", token="...")
|
|
6
|
+
|
|
7
|
+
@worker.on_job
|
|
8
|
+
async def handle(job):
|
|
9
|
+
await my_agent.join(job.livekit_url, job.access_token, job.room_name)
|
|
10
|
+
await job.accept()
|
|
11
|
+
await my_agent.run()
|
|
12
|
+
|
|
13
|
+
asyncio.run(worker.run())
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .job import Call, Job
|
|
17
|
+
from .worker import PiopiyWorker
|
|
18
|
+
|
|
19
|
+
TeleCMIWorker = PiopiyWorker # compatibility alias (pre-rename)
|
|
20
|
+
|
|
21
|
+
__all__ = ["PiopiyWorker", "TeleCMIWorker", "Job", "Call"]
|
|
22
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Connectivity and credentials check.
|
|
2
|
+
|
|
3
|
+
python -m piopiy_agent
|
|
4
|
+
|
|
5
|
+
No agent framework, no media server, no API keys - just the TeleCMI handshake.
|
|
6
|
+
Run it first: it separates "my credentials are wrong" from "my agent code is
|
|
7
|
+
wrong", which otherwise produce identical symptoms.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
import grpc
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
# Optional. Most agent projects keep credentials in a .env, and a check
|
|
19
|
+
# that silently ignored it would report "not set" for values the customer
|
|
20
|
+
# can see in their own file.
|
|
21
|
+
from dotenv import load_dotenv
|
|
22
|
+
|
|
23
|
+
load_dotenv()
|
|
24
|
+
except ImportError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
from ._pb import agent_pb2 as pb
|
|
28
|
+
from ._pb import agent_pb2_grpc as pb_grpc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def check() -> int:
|
|
32
|
+
register = os.getenv("PIOPIY_REGISTER") or os.getenv("PIOPIY_REGISTER", "grpc.piopiy.com:50051")
|
|
33
|
+
agent_id = os.getenv("PIOPIY_AGENT_ID") or os.getenv("TELECMI_AGENT_ID", "")
|
|
34
|
+
token = os.getenv("PIOPIY_TOKEN") or os.getenv("TELECMI_TOKEN", "")
|
|
35
|
+
tls = (os.getenv("PIOPIY_TLS") or os.getenv("PIOPIY_TLS", "true")).lower() != "false"
|
|
36
|
+
|
|
37
|
+
print(f"register {register} (tls={tls})")
|
|
38
|
+
print(f"agent_id {agent_id or '(not set)'}")
|
|
39
|
+
print(f"token {token[:12] + '...' if token else '(not set)'}")
|
|
40
|
+
print()
|
|
41
|
+
|
|
42
|
+
if not agent_id or not token:
|
|
43
|
+
print("FAIL set PIOPIY_AGENT_ID and PIOPIY_TOKEN")
|
|
44
|
+
return 1
|
|
45
|
+
|
|
46
|
+
if tls:
|
|
47
|
+
channel = grpc.aio.secure_channel(register, grpc.ssl_channel_credentials())
|
|
48
|
+
else:
|
|
49
|
+
channel = grpc.aio.insecure_channel(register)
|
|
50
|
+
|
|
51
|
+
async with channel:
|
|
52
|
+
stub = pb_grpc.AgentGatewayStub(channel)
|
|
53
|
+
outbox: asyncio.Queue = asyncio.Queue()
|
|
54
|
+
|
|
55
|
+
async def outgoing():
|
|
56
|
+
while True:
|
|
57
|
+
message = await outbox.get()
|
|
58
|
+
if message is None:
|
|
59
|
+
return
|
|
60
|
+
yield message
|
|
61
|
+
|
|
62
|
+
started = time.monotonic()
|
|
63
|
+
stream = stub.Connect(outgoing(), metadata=(("authorization", f"Bearer {token}"),))
|
|
64
|
+
|
|
65
|
+
await outbox.put(pb.WorkerMessage(register=pb.Register(
|
|
66
|
+
agent_id=agent_id,
|
|
67
|
+
instance_id="connectivity-check",
|
|
68
|
+
max_sessions=1,
|
|
69
|
+
runtime="piopiy_agent.check",
|
|
70
|
+
)))
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
async for message in stream:
|
|
74
|
+
if message.WhichOneof("payload") == "registered":
|
|
75
|
+
took = int((time.monotonic() - started) * 1000)
|
|
76
|
+
print(f"OK registered in {took}ms")
|
|
77
|
+
print(f" accept_deadline_ms {message.registered.accept_deadline_ms}")
|
|
78
|
+
print(f" heartbeat every {message.registered.heartbeat_interval_s}s")
|
|
79
|
+
print()
|
|
80
|
+
print("Credentials work and the register is reachable.")
|
|
81
|
+
await outbox.put(None)
|
|
82
|
+
return 0
|
|
83
|
+
except grpc.aio.AioRpcError as err:
|
|
84
|
+
code = err.code().name
|
|
85
|
+
print(f"FAIL {code}: {err.details()}")
|
|
86
|
+
print()
|
|
87
|
+
if code == "PERMISSION_DENIED":
|
|
88
|
+
print("The token and agent_id do not go together. Either the token")
|
|
89
|
+
print("is not valid, or it belongs to an org that does not own this")
|
|
90
|
+
print("agent. Both are issued together in the dashboard.")
|
|
91
|
+
elif code == "UNAVAILABLE":
|
|
92
|
+
details = err.details() or ""
|
|
93
|
+
if "WRONG_VERSION_NUMBER" in details or "TSI_PROTOCOL_FAILURE" in details:
|
|
94
|
+
# Speaking TLS to a plaintext port. The raw error names
|
|
95
|
+
# OpenSSL and a certificate context, neither of which is
|
|
96
|
+
# the problem, so say what actually is.
|
|
97
|
+
print("The register is not speaking TLS on that port.")
|
|
98
|
+
print()
|
|
99
|
+
print(" PIOPIY_TLS=false python -m piopiy_agent")
|
|
100
|
+
print()
|
|
101
|
+
print("Use TLS only against a register that terminates it.")
|
|
102
|
+
elif "CERTIFICATE_VERIFY_FAILED" in details or "certificate" in details.lower():
|
|
103
|
+
print("TLS connected but the certificate was not trusted. Check the")
|
|
104
|
+
print("hostname matches the certificate, and that the CA is trusted.")
|
|
105
|
+
else:
|
|
106
|
+
print("Could not reach the register. Check the address, and whether")
|
|
107
|
+
print("PIOPIY_TLS matches what the server expects.")
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
sys.exit(asyncio.run(check()))
|
|
File without changes
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# source: telecmi/agent/v1/agent.proto
|
|
4
|
+
# Protobuf Python Version: 4.25.1
|
|
5
|
+
"""Generated protocol buffer code."""
|
|
6
|
+
from google.protobuf import descriptor as _descriptor
|
|
7
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
8
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
9
|
+
from google.protobuf.internal import builder as _builder
|
|
10
|
+
# @@protoc_insertion_point(imports)
|
|
11
|
+
|
|
12
|
+
_sym_db = _symbol_database.Default()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ctelecmi/agent/v1/agent.proto\x12\x10telecmi.agent.v1\"\xc9\x02\n\rWorkerMessage\x12.\n\x08register\x18\x01 \x01(\x0b\x32\x1a.telecmi.agent.v1.RegisterH\x00\x12\x30\n\x06status\x18\x02 \x01(\x0b\x32\x1e.telecmi.agent.v1.StatusUpdateH\x00\x12\x31\n\x08\x61\x63\x63\x65pted\x18\x03 \x01(\x0b\x32\x1d.telecmi.agent.v1.JobAcceptedH\x00\x12\x31\n\x08rejected\x18\x04 \x01(\x0b\x32\x1d.telecmi.agent.v1.JobRejectedH\x00\x12\x33\n\tcompleted\x18\x05 \x01(\x0b\x32\x1e.telecmi.agent.v1.JobCompletedH\x00\x12\x30\n\theartbeat\x18\x06 \x01(\x0b\x32\x1b.telecmi.agent.v1.HeartbeatH\x00\x42\t\n\x07payload\"\xd0\x01\n\x08Register\x12\x10\n\x08\x61gent_id\x18\x01 \x01(\t\x12\x0f\n\x07\x61pi_key\x18\x02 \x01(\t\x12\x13\n\x0binstance_id\x18\x03 \x01(\t\x12\x14\n\x0cmax_sessions\x18\x04 \x01(\r\x12\x0f\n\x07runtime\x18\x05 \x01(\t\x12\x36\n\x06labels\x18\x06 \x03(\x0b\x32&.telecmi.agent.v1.Register.LabelsEntry\x1a-\n\x0bLabelsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x7f\n\x0cStatusUpdate\x12\x17\n\x0f\x61\x63tive_sessions\x18\x01 \x01(\r\x12\x17\n\x0f\x61vailable_slots\x18\x02 \x01(\r\x12-\n\x05state\x18\x03 \x01(\x0e\x32\x1e.telecmi.agent.v1.ServingState\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\t\"_\n\x0bJobAccepted\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x11\n\troom_name\x18\x02 \x01(\t\x12\x1c\n\x14participant_identity\x18\x03 \x01(\t\x12\x0f\n\x07join_ms\x18\x04 \x01(\r\"]\n\x0bJobRejected\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12.\n\x06reason\x18\x02 \x01(\x0e\x32\x1e.telecmi.agent.v1.RejectReason\x12\x0e\n\x06\x64\x65tail\x18\x03 \x01(\t\"R\n\x0cJobCompleted\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x13\n\x0b\x64uration_ms\x18\x02 \x01(\r\x12\r\n\x05\x65rror\x18\x03 \x01(\x08\x12\x0e\n\x06\x64\x65tail\x18\x04 \x01(\t\"!\n\tHeartbeat\x12\x14\n\x0csent_unix_ms\x18\x01 \x01(\x04\"\x8d\x02\n\x11\x44ispatcherMessage\x12\x32\n\nregistered\x18\x01 \x01(\x0b\x32\x1c.telecmi.agent.v1.RegisteredH\x00\x12.\n\x03job\x18\x02 \x01(\x0b\x32\x1f.telecmi.agent.v1.JobAssignmentH\x00\x12-\n\x06\x63\x61ncel\x18\x03 \x01(\x0b\x32\x1b.telecmi.agent.v1.JobCancelH\x00\x12(\n\x05\x64rain\x18\x04 \x01(\x0b\x32\x17.telecmi.agent.v1.DrainH\x00\x12\x30\n\x06hb_ack\x18\x05 \x01(\x0b\x32\x1e.telecmi.agent.v1.HeartbeatAckH\x00\x42\t\n\x07payload\"Z\n\nRegistered\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x1c\n\x14heartbeat_interval_s\x18\x02 \x01(\r\x12\x1a\n\x12\x61\x63\x63\x65pt_deadline_ms\x18\x03 \x01(\r\"\xb8\x01\n\rJobAssignment\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x11\n\troom_name\x18\x02 \x01(\t\x12\x13\n\x0blivekit_url\x18\x03 \x01(\t\x12\x14\n\x0c\x61\x63\x63\x65ss_token\x18\x04 \x01(\t\x12\x1a\n\x12\x61\x63\x63\x65pt_deadline_ms\x18\x05 \x01(\r\x12+\n\x04\x63\x61ll\x18\x06 \x01(\x0b\x32\x1d.telecmi.agent.v1.CallContext\x12\x10\n\x08trace_id\x18\x07 \x01(\t\"\xc5\x02\n\x0b\x43\x61llContext\x12\x11\n\tcall_uuid\x18\x01 \x01(\t\x12\x13\n\x0b\x66rom_number\x18\x02 \x01(\t\x12\x11\n\tto_number\x18\x03 \x01(\t\x12\x11\n\tdirection\x18\x04 \x01(\t\x12\x42\n\x0bsip_headers\x18\x05 \x03(\x0b\x32-.telecmi.agent.v1.CallContext.SipHeadersEntry\x12?\n\tvariables\x18\x06 \x03(\x0b\x32,.telecmi.agent.v1.CallContext.VariablesEntry\x1a\x31\n\x0fSipHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x1a\x30\n\x0eVariablesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"+\n\tJobCancel\x12\x0e\n\x06job_id\x18\x01 \x01(\t\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\x1e\n\x05\x44rain\x12\x15\n\rgrace_seconds\x18\x01 \x01(\r\"$\n\x0cHeartbeatAck\x12\x14\n\x0csent_unix_ms\x18\x01 \x01(\x04*Y\n\x0cServingState\x12\x1d\n\x19SERVING_STATE_UNSPECIFIED\x10\x00\x12\x0b\n\x07SERVING\x10\x01\x12\x0c\n\x08\x44RAINING\x10\x02\x12\x0f\n\x0bNOT_SERVING\x10\x03*y\n\x0cRejectReason\x12\x1d\n\x19REJECT_REASON_UNSPECIFIED\x10\x00\x12\x0f\n\x0b\x41T_CAPACITY\x10\x01\x12\x14\n\x10ROOM_JOIN_FAILED\x10\x02\x12\x10\n\x0c\x43ONFIG_ERROR\x10\x03\x12\x11\n\rSHUTTING_DOWN\x10\x04\x32\x63\n\x0c\x41gentGateway\x12S\n\x07\x43onnect\x12\x1f.telecmi.agent.v1.WorkerMessage\x1a#.telecmi.agent.v1.DispatcherMessage(\x01\x30\x01\x42X\n\x14\x63om.telecmi.agent.v1P\x01Z>github.com/telecmi/agent-proto/gen/go/telecmi/agent/v1;agentv1b\x06proto3')
|
|
18
|
+
|
|
19
|
+
_globals = globals()
|
|
20
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
21
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'telecmi.agent.v1.agent_pb2', _globals)
|
|
22
|
+
if _descriptor._USE_C_DESCRIPTORS == False:
|
|
23
|
+
_globals['DESCRIPTOR']._options = None
|
|
24
|
+
_globals['DESCRIPTOR']._serialized_options = b'\n\024com.telecmi.agent.v1P\001Z>github.com/telecmi/agent-proto/gen/go/telecmi/agent/v1;agentv1'
|
|
25
|
+
_globals['_REGISTER_LABELSENTRY']._options = None
|
|
26
|
+
_globals['_REGISTER_LABELSENTRY']._serialized_options = b'8\001'
|
|
27
|
+
_globals['_CALLCONTEXT_SIPHEADERSENTRY']._options = None
|
|
28
|
+
_globals['_CALLCONTEXT_SIPHEADERSENTRY']._serialized_options = b'8\001'
|
|
29
|
+
_globals['_CALLCONTEXT_VARIABLESENTRY']._options = None
|
|
30
|
+
_globals['_CALLCONTEXT_VARIABLESENTRY']._serialized_options = b'8\001'
|
|
31
|
+
_globals['_SERVINGSTATE']._serialized_start=2027
|
|
32
|
+
_globals['_SERVINGSTATE']._serialized_end=2116
|
|
33
|
+
_globals['_REJECTREASON']._serialized_start=2118
|
|
34
|
+
_globals['_REJECTREASON']._serialized_end=2239
|
|
35
|
+
_globals['_WORKERMESSAGE']._serialized_start=51
|
|
36
|
+
_globals['_WORKERMESSAGE']._serialized_end=380
|
|
37
|
+
_globals['_REGISTER']._serialized_start=383
|
|
38
|
+
_globals['_REGISTER']._serialized_end=591
|
|
39
|
+
_globals['_REGISTER_LABELSENTRY']._serialized_start=546
|
|
40
|
+
_globals['_REGISTER_LABELSENTRY']._serialized_end=591
|
|
41
|
+
_globals['_STATUSUPDATE']._serialized_start=593
|
|
42
|
+
_globals['_STATUSUPDATE']._serialized_end=720
|
|
43
|
+
_globals['_JOBACCEPTED']._serialized_start=722
|
|
44
|
+
_globals['_JOBACCEPTED']._serialized_end=817
|
|
45
|
+
_globals['_JOBREJECTED']._serialized_start=819
|
|
46
|
+
_globals['_JOBREJECTED']._serialized_end=912
|
|
47
|
+
_globals['_JOBCOMPLETED']._serialized_start=914
|
|
48
|
+
_globals['_JOBCOMPLETED']._serialized_end=996
|
|
49
|
+
_globals['_HEARTBEAT']._serialized_start=998
|
|
50
|
+
_globals['_HEARTBEAT']._serialized_end=1031
|
|
51
|
+
_globals['_DISPATCHERMESSAGE']._serialized_start=1034
|
|
52
|
+
_globals['_DISPATCHERMESSAGE']._serialized_end=1303
|
|
53
|
+
_globals['_REGISTERED']._serialized_start=1305
|
|
54
|
+
_globals['_REGISTERED']._serialized_end=1395
|
|
55
|
+
_globals['_JOBASSIGNMENT']._serialized_start=1398
|
|
56
|
+
_globals['_JOBASSIGNMENT']._serialized_end=1582
|
|
57
|
+
_globals['_CALLCONTEXT']._serialized_start=1585
|
|
58
|
+
_globals['_CALLCONTEXT']._serialized_end=1910
|
|
59
|
+
_globals['_CALLCONTEXT_SIPHEADERSENTRY']._serialized_start=1811
|
|
60
|
+
_globals['_CALLCONTEXT_SIPHEADERSENTRY']._serialized_end=1860
|
|
61
|
+
_globals['_CALLCONTEXT_VARIABLESENTRY']._serialized_start=1862
|
|
62
|
+
_globals['_CALLCONTEXT_VARIABLESENTRY']._serialized_end=1910
|
|
63
|
+
_globals['_JOBCANCEL']._serialized_start=1912
|
|
64
|
+
_globals['_JOBCANCEL']._serialized_end=1955
|
|
65
|
+
_globals['_DRAIN']._serialized_start=1957
|
|
66
|
+
_globals['_DRAIN']._serialized_end=1987
|
|
67
|
+
_globals['_HEARTBEATACK']._serialized_start=1989
|
|
68
|
+
_globals['_HEARTBEATACK']._serialized_end=2025
|
|
69
|
+
_globals['_AGENTGATEWAY']._serialized_start=2241
|
|
70
|
+
_globals['_AGENTGATEWAY']._serialized_end=2340
|
|
71
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
|
|
5
|
+
from . import agent_pb2 as telecmi_dot_agent_dot_v1_dot_agent__pb2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentGatewayStub(object):
|
|
9
|
+
"""AgentGateway is the contract between the TeleCMI dispatcher and a
|
|
10
|
+
customer-operated voice agent runtime (Pipecat, LiveKit Agents, or custom).
|
|
11
|
+
|
|
12
|
+
The worker connects OUT to us and holds the stream open. We never connect to
|
|
13
|
+
the worker, so customers do not need inbound firewall rules or public IPs.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, channel):
|
|
17
|
+
"""Constructor.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
channel: A grpc.Channel.
|
|
21
|
+
"""
|
|
22
|
+
self.Connect = channel.stream_stream(
|
|
23
|
+
'/telecmi.agent.v1.AgentGateway/Connect',
|
|
24
|
+
request_serializer=telecmi_dot_agent_dot_v1_dot_agent__pb2.WorkerMessage.SerializeToString,
|
|
25
|
+
response_deserializer=telecmi_dot_agent_dot_v1_dot_agent__pb2.DispatcherMessage.FromString,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AgentGatewayServicer(object):
|
|
30
|
+
"""AgentGateway is the contract between the TeleCMI dispatcher and a
|
|
31
|
+
customer-operated voice agent runtime (Pipecat, LiveKit Agents, or custom).
|
|
32
|
+
|
|
33
|
+
The worker connects OUT to us and holds the stream open. We never connect to
|
|
34
|
+
the worker, so customers do not need inbound firewall rules or public IPs.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def Connect(self, request_iterator, context):
|
|
38
|
+
"""Long-lived bidirectional stream. The worker opens it at startup and holds
|
|
39
|
+
it for the life of the process.
|
|
40
|
+
|
|
41
|
+
Authentication is per-RPC metadata: `authorization: Bearer <api_key>`,
|
|
42
|
+
validated when the stream opens. A key maps to exactly one
|
|
43
|
+
{tenant_id, agent_id}; registering with an agent_id the key does not own
|
|
44
|
+
fails with PERMISSION_DENIED.
|
|
45
|
+
"""
|
|
46
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
47
|
+
context.set_details('Method not implemented!')
|
|
48
|
+
raise NotImplementedError('Method not implemented!')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def add_AgentGatewayServicer_to_server(servicer, server):
|
|
52
|
+
rpc_method_handlers = {
|
|
53
|
+
'Connect': grpc.stream_stream_rpc_method_handler(
|
|
54
|
+
servicer.Connect,
|
|
55
|
+
request_deserializer=telecmi_dot_agent_dot_v1_dot_agent__pb2.WorkerMessage.FromString,
|
|
56
|
+
response_serializer=telecmi_dot_agent_dot_v1_dot_agent__pb2.DispatcherMessage.SerializeToString,
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
60
|
+
'telecmi.agent.v1.AgentGateway', rpc_method_handlers)
|
|
61
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# This class is part of an EXPERIMENTAL API.
|
|
65
|
+
class AgentGateway(object):
|
|
66
|
+
"""AgentGateway is the contract between the TeleCMI dispatcher and a
|
|
67
|
+
customer-operated voice agent runtime (Pipecat, LiveKit Agents, or custom).
|
|
68
|
+
|
|
69
|
+
The worker connects OUT to us and holds the stream open. We never connect to
|
|
70
|
+
the worker, so customers do not need inbound firewall rules or public IPs.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def Connect(request_iterator,
|
|
75
|
+
target,
|
|
76
|
+
options=(),
|
|
77
|
+
channel_credentials=None,
|
|
78
|
+
call_credentials=None,
|
|
79
|
+
insecure=False,
|
|
80
|
+
compression=None,
|
|
81
|
+
wait_for_ready=None,
|
|
82
|
+
timeout=None,
|
|
83
|
+
metadata=None):
|
|
84
|
+
return grpc.experimental.stream_stream(request_iterator, target, '/telecmi.agent.v1.AgentGateway/Connect',
|
|
85
|
+
telecmi_dot_agent_dot_v1_dot_agent__pb2.WorkerMessage.SerializeToString,
|
|
86
|
+
telecmi_dot_agent_dot_v1_dot_agent__pb2.DispatcherMessage.FromString,
|
|
87
|
+
options, channel_credentials,
|
|
88
|
+
insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""One call, offered to this worker."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Call:
|
|
10
|
+
"""Who is calling, and who they dialled."""
|
|
11
|
+
|
|
12
|
+
call_uuid: str
|
|
13
|
+
from_number: str
|
|
14
|
+
to_number: str
|
|
15
|
+
direction: str
|
|
16
|
+
sip_headers: Dict[str, str]
|
|
17
|
+
variables: Dict[str, str]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Job:
|
|
21
|
+
"""A call offered to you. Join the room, then accept.
|
|
22
|
+
|
|
23
|
+
Everything needed to join is here: `livekit_url`, `access_token` and
|
|
24
|
+
`room_name`. The token is pre-minted and scoped to this one room, so you
|
|
25
|
+
never hold LiveKit admin credentials.
|
|
26
|
+
|
|
27
|
+
The caller is bridged in the moment you accept, so accept only once you are
|
|
28
|
+
ACTUALLY in the room. Accept early and they arrive to silence.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message, worker, *, deadline_ms: int):
|
|
32
|
+
self.job_id: str = message.job_id
|
|
33
|
+
self.room_name: str = message.room_name
|
|
34
|
+
self.livekit_url: str = message.livekit_url
|
|
35
|
+
self.access_token: str = message.access_token
|
|
36
|
+
self.trace_id: str = message.trace_id
|
|
37
|
+
|
|
38
|
+
self.call = Call(
|
|
39
|
+
call_uuid=message.call.call_uuid,
|
|
40
|
+
from_number=message.call.from_number,
|
|
41
|
+
to_number=message.call.to_number,
|
|
42
|
+
direction=message.call.direction,
|
|
43
|
+
sip_headers=dict(message.call.sip_headers),
|
|
44
|
+
variables=dict(message.call.variables),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self._worker = worker
|
|
48
|
+
self._deadline_ms = deadline_ms
|
|
49
|
+
self._offered_at = time.monotonic()
|
|
50
|
+
self._settled = False
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def deadline_ms(self) -> int:
|
|
54
|
+
"""How long you have to join and accept."""
|
|
55
|
+
return self._deadline_ms
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def remaining_ms(self) -> int:
|
|
59
|
+
"""Time left before this offer expires."""
|
|
60
|
+
spent = (time.monotonic() - self._offered_at) * 1000
|
|
61
|
+
return max(0, int(self._deadline_ms - spent))
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def settled(self) -> bool:
|
|
65
|
+
return self._settled
|
|
66
|
+
|
|
67
|
+
async def accept(self, *, participant_identity: Optional[str] = None) -> bool:
|
|
68
|
+
"""Tell TeleCMI you are in the room. Call this AFTER joining.
|
|
69
|
+
|
|
70
|
+
Returns False if the deadline has already passed - in which case the
|
|
71
|
+
call has gone to another instance and you must leave the room. The SDK
|
|
72
|
+
will not send a late accept, because that is how two agents end up on
|
|
73
|
+
one call.
|
|
74
|
+
"""
|
|
75
|
+
if self._settled:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
join_ms = int((time.monotonic() - self._offered_at) * 1000)
|
|
79
|
+
|
|
80
|
+
if join_ms > self._deadline_ms:
|
|
81
|
+
self._settled = True
|
|
82
|
+
await self._worker._on_late_join(self, join_ms)
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
self._settled = True
|
|
86
|
+
await self._worker._send_accepted(self, join_ms, participant_identity)
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
async def reject(self, reason: str = "AT_CAPACITY", detail: str = "") -> None:
|
|
90
|
+
"""Decline it. A fast, honest no is cheap; silence costs the caller."""
|
|
91
|
+
if self._settled:
|
|
92
|
+
return
|
|
93
|
+
self._settled = True
|
|
94
|
+
await self._worker._send_rejected(self, reason, detail)
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""The TeleCMI worker: connect, register, take calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import time
|
|
8
|
+
from typing import Awaitable, Callable, Dict, Optional
|
|
9
|
+
|
|
10
|
+
import grpc
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
load_dotenv()
|
|
16
|
+
except ImportError:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
from ._pb import agent_pb2 as pb
|
|
20
|
+
from ._pb import agent_pb2_grpc as pb_grpc
|
|
21
|
+
from .job import Job
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("piopiy_agent")
|
|
24
|
+
|
|
25
|
+
JobHandler = Callable[[Job], Awaitable[None]]
|
|
26
|
+
|
|
27
|
+
# Transport keepalive is how TeleCMI notices a wedged process that is still
|
|
28
|
+
# holding its TCP connection. Heartbeat carries capacity, not liveness.
|
|
29
|
+
_CHANNEL_OPTIONS = [
|
|
30
|
+
("grpc.keepalive_time_ms", 10_000),
|
|
31
|
+
("grpc.keepalive_timeout_ms", 20_000),
|
|
32
|
+
("grpc.keepalive_permit_without_calls", 0),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PiopiyWorker:
|
|
37
|
+
"""Connects out to TeleCMI and holds one long-lived stream.
|
|
38
|
+
|
|
39
|
+
TeleCMI never connects to you, so no inbound firewall rules and no public
|
|
40
|
+
IP are needed.
|
|
41
|
+
|
|
42
|
+
worker = PiopiyWorker(agent_id=..., token=...)
|
|
43
|
+
|
|
44
|
+
@worker.on_job
|
|
45
|
+
async def handle(job):
|
|
46
|
+
await my_agent.join(job.livekit_url, job.access_token, job.room_name)
|
|
47
|
+
await job.accept() # only once you are IN the room
|
|
48
|
+
await my_agent.run() # returns when the call ends
|
|
49
|
+
|
|
50
|
+
asyncio.run(worker.run())
|
|
51
|
+
|
|
52
|
+
The SDK handles registration, heartbeats, capacity reporting, reconnects
|
|
53
|
+
and the accept deadline. You handle the call.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
agent_id: Optional[str] = None,
|
|
60
|
+
token: Optional[str] = None,
|
|
61
|
+
register: Optional[str] = None,
|
|
62
|
+
max_sessions: int = 10,
|
|
63
|
+
instance_id: Optional[str] = None,
|
|
64
|
+
tls: Optional[bool] = None,
|
|
65
|
+
runtime: str = "piopiy-agent-python/1.0.0",
|
|
66
|
+
labels: Optional[Dict[str, str]] = None,
|
|
67
|
+
):
|
|
68
|
+
self.agent_id = agent_id or os.environ.get("PIOPIY_AGENT_ID") or os.environ["TELECMI_AGENT_ID"]
|
|
69
|
+
self.token = token or os.environ.get("PIOPIY_TOKEN") or os.environ["TELECMI_TOKEN"]
|
|
70
|
+
self.register = register or os.getenv("PIOPIY_REGISTER") or os.getenv("PIOPIY_REGISTER", "grpc.piopiy.com:50051")
|
|
71
|
+
self.max_sessions = max_sessions
|
|
72
|
+
self.instance_id = instance_id or (os.getenv("PIOPIY_INSTANCE_ID") or os.getenv("TELECMI_INSTANCE_ID")) \
|
|
73
|
+
or f"{socket.gethostname()}-{os.getpid()}"
|
|
74
|
+
self.runtime = runtime
|
|
75
|
+
self.labels = labels or {}
|
|
76
|
+
|
|
77
|
+
if tls is None:
|
|
78
|
+
tls = (os.getenv("PIOPIY_TLS") or os.getenv("PIOPIY_TLS", "true")).lower() != "false"
|
|
79
|
+
self.tls = tls
|
|
80
|
+
|
|
81
|
+
self._handler: Optional[JobHandler] = None
|
|
82
|
+
self._outbox: asyncio.Queue = asyncio.Queue()
|
|
83
|
+
self._active: Dict[str, Job] = {}
|
|
84
|
+
self._tasks: Dict[str, asyncio.Task] = {}
|
|
85
|
+
self._draining = False
|
|
86
|
+
|
|
87
|
+
# False until one call has actually been accepted. A gRPC channel opens
|
|
88
|
+
# long before a Python process can reach a media server, and a fresh
|
|
89
|
+
# instance reports zero active sessions - so it looks like the BEST
|
|
90
|
+
# candidate exactly when it is least able to answer. Reporting
|
|
91
|
+
# NOT_SERVING until proven is what stops that.
|
|
92
|
+
self._proven = False
|
|
93
|
+
|
|
94
|
+
self._accept_deadline_ms = 400
|
|
95
|
+
self._heartbeat_s = 10
|
|
96
|
+
|
|
97
|
+
# ---------- registration ----------
|
|
98
|
+
|
|
99
|
+
def on_job(self, handler: JobHandler) -> JobHandler:
|
|
100
|
+
"""Register the coroutine that handles a call."""
|
|
101
|
+
self._handler = handler
|
|
102
|
+
return handler
|
|
103
|
+
|
|
104
|
+
async def run(self) -> None:
|
|
105
|
+
"""Connect and serve until stopped. Reconnects on its own."""
|
|
106
|
+
if self._handler is None:
|
|
107
|
+
raise RuntimeError("no job handler registered - use @worker.on_job")
|
|
108
|
+
|
|
109
|
+
backoff = 1
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
await self._session()
|
|
113
|
+
backoff = 1
|
|
114
|
+
except grpc.aio.AioRpcError as err:
|
|
115
|
+
# A TeleCMI restart ends the stream with CANCELLED, not
|
|
116
|
+
# UNAVAILABLE. CANCELLED is non-retryable by gRPC convention,
|
|
117
|
+
# so a client that reconnects only on UNAVAILABLE stays down
|
|
118
|
+
# after every deploy. Reconnect on any end of stream.
|
|
119
|
+
log.warning("stream ended: %s %s", err.code().name, err.details())
|
|
120
|
+
except asyncio.CancelledError:
|
|
121
|
+
raise
|
|
122
|
+
except Exception:
|
|
123
|
+
log.exception("session failed")
|
|
124
|
+
|
|
125
|
+
if self._draining and not self._active:
|
|
126
|
+
log.info("drained, stopping")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
log.info("reconnecting in %ss", backoff)
|
|
130
|
+
await asyncio.sleep(backoff)
|
|
131
|
+
backoff = min(backoff * 2, 30)
|
|
132
|
+
|
|
133
|
+
async def _session(self) -> None:
|
|
134
|
+
if self.tls:
|
|
135
|
+
channel = grpc.aio.secure_channel(
|
|
136
|
+
self.register, grpc.ssl_channel_credentials(), options=_CHANNEL_OPTIONS
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
channel = grpc.aio.insecure_channel(self.register, options=_CHANNEL_OPTIONS)
|
|
140
|
+
|
|
141
|
+
async with channel:
|
|
142
|
+
stub = pb_grpc.AgentGatewayStub(channel)
|
|
143
|
+
# The token goes in metadata; the api_key field on Register exists
|
|
144
|
+
# only for early clients.
|
|
145
|
+
metadata = (("authorization", f"Bearer {self.token}"),)
|
|
146
|
+
stream = stub.Connect(self._outgoing(), metadata=metadata)
|
|
147
|
+
|
|
148
|
+
await self._send(pb.WorkerMessage(register=pb.Register(
|
|
149
|
+
agent_id=self.agent_id,
|
|
150
|
+
instance_id=self.instance_id,
|
|
151
|
+
max_sessions=self.max_sessions,
|
|
152
|
+
runtime=self.runtime,
|
|
153
|
+
labels=self.labels,
|
|
154
|
+
)))
|
|
155
|
+
|
|
156
|
+
heartbeat: Optional[asyncio.Task] = None
|
|
157
|
+
try:
|
|
158
|
+
async for message in stream:
|
|
159
|
+
kind = message.WhichOneof("payload")
|
|
160
|
+
|
|
161
|
+
if kind == "registered":
|
|
162
|
+
self._accept_deadline_ms = message.registered.accept_deadline_ms or 400
|
|
163
|
+
self._heartbeat_s = message.registered.heartbeat_interval_s or 10
|
|
164
|
+
log.info(
|
|
165
|
+
"registered as %s (accept deadline %sms)",
|
|
166
|
+
self.instance_id, self._accept_deadline_ms,
|
|
167
|
+
)
|
|
168
|
+
heartbeat = asyncio.create_task(self._heartbeat_loop())
|
|
169
|
+
|
|
170
|
+
elif kind == "job":
|
|
171
|
+
self._tasks[message.job.job_id] = asyncio.create_task(
|
|
172
|
+
self._run_job(message.job)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
elif kind == "cancel":
|
|
176
|
+
await self._cancel(message.cancel.job_id, message.cancel.reason)
|
|
177
|
+
|
|
178
|
+
elif kind == "drain":
|
|
179
|
+
log.info("draining (%ss grace)", message.drain.grace_seconds)
|
|
180
|
+
self._draining = True
|
|
181
|
+
await self._send_status()
|
|
182
|
+
finally:
|
|
183
|
+
if heartbeat:
|
|
184
|
+
heartbeat.cancel()
|
|
185
|
+
|
|
186
|
+
async def _outgoing(self):
|
|
187
|
+
while True:
|
|
188
|
+
message = await self._outbox.get()
|
|
189
|
+
if message is None:
|
|
190
|
+
return
|
|
191
|
+
yield message
|
|
192
|
+
|
|
193
|
+
async def _send(self, message) -> None:
|
|
194
|
+
await self._outbox.put(message)
|
|
195
|
+
|
|
196
|
+
# ---------- the call ----------
|
|
197
|
+
|
|
198
|
+
async def _run_job(self, message) -> None:
|
|
199
|
+
job = Job(message, self, deadline_ms=message.accept_deadline_ms or self._accept_deadline_ms)
|
|
200
|
+
|
|
201
|
+
if self._draining or len(self._active) >= self.max_sessions:
|
|
202
|
+
reason = "SHUTTING_DOWN" if self._draining else "AT_CAPACITY"
|
|
203
|
+
await job.reject(reason)
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
self._active[job.job_id] = job
|
|
207
|
+
started = time.monotonic()
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
await self._handler(job)
|
|
211
|
+
except asyncio.CancelledError:
|
|
212
|
+
raise
|
|
213
|
+
except Exception as err:
|
|
214
|
+
log.exception("handler failed for %s", job.job_id)
|
|
215
|
+
if not job.settled:
|
|
216
|
+
await job.reject("ROOM_JOIN_FAILED", str(err)[:200])
|
|
217
|
+
return
|
|
218
|
+
finally:
|
|
219
|
+
self._active.pop(job.job_id, None)
|
|
220
|
+
self._tasks.pop(job.job_id, None)
|
|
221
|
+
|
|
222
|
+
if not job.settled:
|
|
223
|
+
# The handler returned without ever accepting. TeleCMI is still
|
|
224
|
+
# waiting, so say no rather than letting the deadline expire - the
|
|
225
|
+
# caller is listening to silence for every millisecond of it.
|
|
226
|
+
log.warning("handler returned without accepting %s", job.job_id)
|
|
227
|
+
await job.reject("CONFIG_ERROR", "handler did not accept")
|
|
228
|
+
return
|
|
229
|
+
|
|
230
|
+
await self._send(pb.WorkerMessage(completed=pb.JobCompleted(
|
|
231
|
+
job_id=job.job_id,
|
|
232
|
+
duration_ms=int((time.monotonic() - started) * 1000),
|
|
233
|
+
)))
|
|
234
|
+
await self._send_status()
|
|
235
|
+
|
|
236
|
+
async def _cancel(self, job_id: str, reason: str) -> None:
|
|
237
|
+
log.info("cancelled %s: %s", job_id, reason)
|
|
238
|
+
task = self._tasks.pop(job_id, None)
|
|
239
|
+
self._active.pop(job_id, None)
|
|
240
|
+
if task:
|
|
241
|
+
task.cancel()
|
|
242
|
+
await self._send_status()
|
|
243
|
+
|
|
244
|
+
# ---------- protocol, called by Job ----------
|
|
245
|
+
|
|
246
|
+
async def _send_accepted(self, job: Job, join_ms: int, identity: Optional[str]) -> None:
|
|
247
|
+
self._proven = True
|
|
248
|
+
await self._send(pb.WorkerMessage(accepted=pb.JobAccepted(
|
|
249
|
+
job_id=job.job_id,
|
|
250
|
+
room_name=job.room_name,
|
|
251
|
+
participant_identity=identity or "",
|
|
252
|
+
join_ms=join_ms,
|
|
253
|
+
)))
|
|
254
|
+
await self._send_status()
|
|
255
|
+
|
|
256
|
+
async def _send_rejected(self, job: Job, reason: str, detail: str) -> None:
|
|
257
|
+
await self._send(pb.WorkerMessage(rejected=pb.JobRejected(
|
|
258
|
+
job_id=job.job_id,
|
|
259
|
+
reason=getattr(pb, reason, pb.REJECT_REASON_UNSPECIFIED),
|
|
260
|
+
detail=detail,
|
|
261
|
+
)))
|
|
262
|
+
|
|
263
|
+
async def _on_late_join(self, job: Job, join_ms: int) -> None:
|
|
264
|
+
# Past the deadline TeleCMI has already offered this call elsewhere.
|
|
265
|
+
# Accepting now would put two agents in one room, so we do not send it.
|
|
266
|
+
log.warning(
|
|
267
|
+
"joined %s after the deadline (%sms > %sms) - leave the room",
|
|
268
|
+
job.job_id, join_ms, job.deadline_ms,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# ---------- health ----------
|
|
272
|
+
|
|
273
|
+
async def _heartbeat_loop(self) -> None:
|
|
274
|
+
while True:
|
|
275
|
+
await self._send(pb.WorkerMessage(
|
|
276
|
+
heartbeat=pb.Heartbeat(sent_unix_ms=int(time.time() * 1000))
|
|
277
|
+
))
|
|
278
|
+
await self._send_status()
|
|
279
|
+
await asyncio.sleep(self._heartbeat_s)
|
|
280
|
+
|
|
281
|
+
async def _send_status(self) -> None:
|
|
282
|
+
# SERVING even before the first call. Reporting NOT_SERVING while
|
|
283
|
+
# merely starting up conflates "new" with "broken", and TeleCMI cannot
|
|
284
|
+
# tell them apart - so a fleet where nothing has proven itself yet
|
|
285
|
+
# would never be offered a call, and could never prove itself.
|
|
286
|
+
#
|
|
287
|
+
# TeleCMI tracks proven-ness on its own side and sorts warm instances
|
|
288
|
+
# ahead of cold ones. Serving late is its job, not ours.
|
|
289
|
+
state = pb.DRAINING if self._draining else pb.SERVING
|
|
290
|
+
|
|
291
|
+
await self._send(pb.WorkerMessage(status=pb.StatusUpdate(
|
|
292
|
+
active_sessions=len(self._active),
|
|
293
|
+
available_slots=max(0, self.max_sessions - len(self._active)),
|
|
294
|
+
state=state,
|
|
295
|
+
)))
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: piopiy-agent
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Connect a voice agent to the TeleCMI telephony platform
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://piopiy.com
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: grpcio>=1.60
|
|
10
|
+
Requires-Dist: protobuf>=4.25
|
|
11
|
+
|
|
12
|
+
# piopiy-agent
|
|
13
|
+
|
|
14
|
+
Connect a voice agent to the TeleCMI telephony platform.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install piopiy-agent
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No `protoc`. No `grpcio-tools`. No protobuf version clash. The stubs ship
|
|
21
|
+
pre-generated, built against an old runtime, so they load under whatever
|
|
22
|
+
protobuf your agent framework pins.
|
|
23
|
+
|
|
24
|
+
## Check your credentials
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
export PIOPIY_AGENT_ID=agent_42
|
|
28
|
+
export PIOPIY_TOKEN=eyJhbGciOi...
|
|
29
|
+
|
|
30
|
+
python -m piopiy_agent
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A `.env` in the working directory is picked up too, if `python-dotenv` is
|
|
34
|
+
installed.
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
OK registered in 41ms
|
|
38
|
+
accept_deadline_ms 400
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Do this before anything else. It separates "my credentials are wrong" from
|
|
42
|
+
"my agent code is wrong", which otherwise look identical.
|
|
43
|
+
|
|
44
|
+
## Take calls
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
from piopiy_agent import PiopiyWorker
|
|
49
|
+
|
|
50
|
+
worker = PiopiyWorker(max_sessions=10)
|
|
51
|
+
|
|
52
|
+
@worker.on_job
|
|
53
|
+
async def handle(job):
|
|
54
|
+
# Everything needed to join is on the job. The token is pre-minted and
|
|
55
|
+
# scoped to this one room - you never hold LiveKit admin credentials.
|
|
56
|
+
await my_agent.join(job.livekit_url, job.access_token, job.room_name)
|
|
57
|
+
|
|
58
|
+
# ONLY once you are actually in the room. The caller is bridged in on
|
|
59
|
+
# the strength of this call.
|
|
60
|
+
await job.accept()
|
|
61
|
+
|
|
62
|
+
await my_agent.run() # returns when the call ends
|
|
63
|
+
|
|
64
|
+
asyncio.run(worker.run())
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`job.call` carries `from_number`, `to_number`, `direction`, `call_uuid` and any
|
|
68
|
+
whitelisted SIP headers.
|
|
69
|
+
|
|
70
|
+
## The one rule
|
|
71
|
+
|
|
72
|
+
**Accept only after you are in the room.**
|
|
73
|
+
|
|
74
|
+
TeleCMI bridges the caller the moment you accept. Accept on *receiving* the
|
|
75
|
+
job and the caller arrives in an empty room and hears silence — which is the
|
|
76
|
+
single failure this whole design exists to prevent.
|
|
77
|
+
|
|
78
|
+
You have `job.deadline_ms` (400 by default) to join. `job.remaining_ms` tells
|
|
79
|
+
you what is left.
|
|
80
|
+
|
|
81
|
+
If you join late, `job.accept()` returns **False** and sends nothing: the call
|
|
82
|
+
has gone to another instance, and a late accept would put two agents on one
|
|
83
|
+
call. Leave the room when you see False.
|
|
84
|
+
|
|
85
|
+
## What the SDK handles so you do not have to
|
|
86
|
+
|
|
87
|
+
- **Registration and reconnects.** A TeleCMI restart ends your stream with
|
|
88
|
+
`CANCELLED`, not `UNAVAILABLE`. `CANCELLED` is non-retryable by gRPC
|
|
89
|
+
convention, so a client reconnecting only on `UNAVAILABLE` silently stays
|
|
90
|
+
down after every deploy. This reconnects on any end of stream, with backoff.
|
|
91
|
+
- **Serving late.** Reports `NOT_SERVING` until one call has actually been
|
|
92
|
+
accepted. A gRPC channel opens long before a Python process can reach a
|
|
93
|
+
media server, and a fresh instance reports zero active sessions — so it
|
|
94
|
+
looks like the *best* candidate exactly when it is least able to answer.
|
|
95
|
+
- **Capacity.** Stops accepting at `max_sessions` and rejects with
|
|
96
|
+
`AT_CAPACITY`. Be honest with this number: a worker that takes more than it
|
|
97
|
+
can serve produces dead air, where a fast rejection costs TeleCMI 2ms and it
|
|
98
|
+
moves on.
|
|
99
|
+
- **Heartbeats and status.**
|
|
100
|
+
- **A handler that returns without accepting** is rejected rather than left to
|
|
101
|
+
time out — the caller is listening to silence for every millisecond of it.
|
|
102
|
+
|
|
103
|
+
## Configuration
|
|
104
|
+
|
|
105
|
+
Constructor arguments, or environment:
|
|
106
|
+
|
|
107
|
+
| | |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `TELECMI_AGENT_ID` | from the dashboard |
|
|
110
|
+
| `TELECMI_TOKEN` | from the dashboard |
|
|
111
|
+
| `PIOPIY_REGISTER` | default `grpc.piopiy.com:50051` |
|
|
112
|
+
| `PIOPIY_TLS` | `false` only against a local register |
|
|
113
|
+
| `TELECMI_INSTANCE_ID` | defaults to hostname-pid |
|
|
114
|
+
|
|
115
|
+
## Why the stubs are vendored
|
|
116
|
+
|
|
117
|
+
Generated protobuf code carries the version it was built with, and the runtime
|
|
118
|
+
refuses to load gencode **newer** than itself. `pip install grpcio-tools`
|
|
119
|
+
resolves protobuf 7.x, while agent frameworks commonly pin 5.x — so generating
|
|
120
|
+
locally produces stubs your own environment cannot import:
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
VersionError: gencode 7.35.1 runtime 5.29.6
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The error names protobuf, not gRPC and not TeleCMI, which is what makes it
|
|
127
|
+
expensive to diagnose. Shipping stubs built against an old runtime removes the
|
|
128
|
+
problem: protobuf accepts a runtime newer than the gencode, never older.
|
|
129
|
+
|
|
130
|
+
Verified against protobuf 4.25, 5.29 and 7.36.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/piopiy_agent/__init__.py
|
|
4
|
+
src/piopiy_agent/__main__.py
|
|
5
|
+
src/piopiy_agent/job.py
|
|
6
|
+
src/piopiy_agent/worker.py
|
|
7
|
+
src/piopiy_agent.egg-info/PKG-INFO
|
|
8
|
+
src/piopiy_agent.egg-info/SOURCES.txt
|
|
9
|
+
src/piopiy_agent.egg-info/dependency_links.txt
|
|
10
|
+
src/piopiy_agent.egg-info/requires.txt
|
|
11
|
+
src/piopiy_agent.egg-info/top_level.txt
|
|
12
|
+
src/piopiy_agent/_pb/__init__.py
|
|
13
|
+
src/piopiy_agent/_pb/agent_pb2.py
|
|
14
|
+
src/piopiy_agent/_pb/agent_pb2_grpc.py
|
|
15
|
+
src/telecmi_agent/__init__.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Compatibility shim: the package is now `piopiy_agent`. This import path
|
|
2
|
+
keeps pre-rename code working and will be removed in 2.0."""
|
|
3
|
+
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
from piopiy_agent import Call, Job, PiopiyWorker
|
|
7
|
+
from piopiy_agent import __version__
|
|
8
|
+
|
|
9
|
+
TeleCMIWorker = PiopiyWorker
|
|
10
|
+
|
|
11
|
+
warnings.warn("telecmi_agent is now piopiy_agent - update your import", DeprecationWarning, stacklevel=2)
|
|
12
|
+
|
|
13
|
+
__all__ = ["TeleCMIWorker", "PiopiyWorker", "Job", "Call", "__version__"]
|