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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ grpcio>=1.60
2
+ protobuf>=4.25
@@ -0,0 +1,2 @@
1
+ piopiy_agent
2
+ telecmi_agent
@@ -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__"]