pinecall-runtime 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. pinecall/__init__.py +0 -0
  2. pinecall/channels/__init__.py +0 -0
  3. pinecall/channels/_chooser.py +45 -0
  4. pinecall/channels/offers.py +219 -0
  5. pinecall/channels/rooms.py +176 -0
  6. pinecall/channels/routes.py +275 -0
  7. pinecall/channels/telephony/__init__.py +0 -0
  8. pinecall/channels/telephony/_twilio.py +349 -0
  9. pinecall/channels/telephony/carrier.py +229 -0
  10. pinecall/channels/telephony/carrier_catalog.py +92 -0
  11. pinecall/channels/telephony/carriers.csv +41 -0
  12. pinecall/channels/telephony/dialing.py +419 -0
  13. pinecall/channels/telephony/firewall.py +58 -0
  14. pinecall/channels/telephony/hand_over.py +78 -0
  15. pinecall/channels/telephony/number_path.py +176 -0
  16. pinecall/channels/telephony/numbers.py +530 -0
  17. pinecall/channels/telephony/origination.py +77 -0
  18. pinecall/channels/telephony/sip.py +448 -0
  19. pinecall/channels/whatsapp.py +413 -0
  20. pinecall/cli/__init__.py +0 -0
  21. pinecall/cli/_archive.py +85 -0
  22. pinecall/cli/_drift.py +38 -0
  23. pinecall/cli/_facts.py +67 -0
  24. pinecall/cli/_load.py +433 -0
  25. pinecall/cli/_operator.py +626 -0
  26. pinecall/cli/_sessions.py +164 -0
  27. pinecall/cli/_traceback.py +95 -0
  28. pinecall/cli/_usage.py +34 -0
  29. pinecall/cli/main.py +521 -0
  30. pinecall/domain/__init__.py +0 -0
  31. pinecall/domain/agent.py +446 -0
  32. pinecall/domain/call.py +152 -0
  33. pinecall/domain/country_codes.py +32 -0
  34. pinecall/domain/errors.py +151 -0
  35. pinecall/domain/names.py +115 -0
  36. pinecall/domain/org.py +120 -0
  37. pinecall/domain/person.py +208 -0
  38. pinecall/domain/scope.py +21 -0
  39. pinecall/evals/__init__.py +0 -0
  40. pinecall/evals/_evidence.py +158 -0
  41. pinecall/evals/calibration.py +112 -0
  42. pinecall/evals/callers.py +160 -0
  43. pinecall/evals/case.py +327 -0
  44. pinecall/evals/checks.py +437 -0
  45. pinecall/evals/compliance.py +197 -0
  46. pinecall/evals/dataset.py +451 -0
  47. pinecall/evals/goldens.py +331 -0
  48. pinecall/evals/judges.py +525 -0
  49. pinecall/evals/runs.py +252 -0
  50. pinecall/evals/spoken.py +356 -0
  51. pinecall/fleet/__init__.py +0 -0
  52. pinecall/fleet/client.py +569 -0
  53. pinecall/fleet/demand.py +51 -0
  54. pinecall/fleet/heartbeat.py +194 -0
  55. pinecall/fleet/measures.py +157 -0
  56. pinecall/fleet/roster.py +232 -0
  57. pinecall/fleet/worlds.py +44 -0
  58. pinecall/gateway/__init__.py +0 -0
  59. pinecall/gateway/_call_setup.py +53 -0
  60. pinecall/gateway/_deps.py +556 -0
  61. pinecall/gateway/_gateway.py +75 -0
  62. pinecall/gateway/_served.py +396 -0
  63. pinecall/gateway/_sockets.py +444 -0
  64. pinecall/gateway/_streams.py +83 -0
  65. pinecall/gateway/_text_calls.py +182 -0
  66. pinecall/gateway/api/__init__.py +0 -0
  67. pinecall/gateway/api/accounts.py +324 -0
  68. pinecall/gateway/api/agents.py +150 -0
  69. pinecall/gateway/api/apps.py +478 -0
  70. pinecall/gateway/api/box.py +658 -0
  71. pinecall/gateway/api/calibration.py +58 -0
  72. pinecall/gateway/api/callbacks.py +105 -0
  73. pinecall/gateway/api/calls.py +655 -0
  74. pinecall/gateway/api/chat.py +223 -0
  75. pinecall/gateway/api/dataset.py +163 -0
  76. pinecall/gateway/api/desk.py +113 -0
  77. pinecall/gateway/api/entries.py +77 -0
  78. pinecall/gateway/api/evals.py +647 -0
  79. pinecall/gateway/api/fleet.py +73 -0
  80. pinecall/gateway/api/hosting.py +241 -0
  81. pinecall/gateway/api/judges.py +97 -0
  82. pinecall/gateway/api/keys.py +110 -0
  83. pinecall/gateway/api/line.py +142 -0
  84. pinecall/gateway/api/members.py +197 -0
  85. pinecall/gateway/api/metrics.py +226 -0
  86. pinecall/gateway/api/numbers.py +476 -0
  87. pinecall/gateway/api/ops.py +428 -0
  88. pinecall/gateway/api/org.py +283 -0
  89. pinecall/gateway/api/personas.py +157 -0
  90. pinecall/gateway/api/pipeline.py +206 -0
  91. pinecall/gateway/api/prompts.py +50 -0
  92. pinecall/gateway/api/providers.py +240 -0
  93. pinecall/gateway/api/recordings.py +89 -0
  94. pinecall/gateway/api/relay.py +169 -0
  95. pinecall/gateway/api/retrieval.py +403 -0
  96. pinecall/gateway/api/runner.py +109 -0
  97. pinecall/gateway/api/sessions.py +102 -0
  98. pinecall/gateway/api/settings.py +463 -0
  99. pinecall/gateway/api/signup.py +164 -0
  100. pinecall/gateway/api/sso_login.py +168 -0
  101. pinecall/gateway/api/threads.py +187 -0
  102. pinecall/gateway/api/usage.py +303 -0
  103. pinecall/gateway/api/visitors.py +222 -0
  104. pinecall/gateway/api/whatsapp.py +48 -0
  105. pinecall/gateway/api/widget.py +49 -0
  106. pinecall/gateway/app.py +504 -0
  107. pinecall/gateway/calls/__init__.py +0 -0
  108. pinecall/gateway/calls/binding.py +63 -0
  109. pinecall/gateway/calls/commands.py +18 -0
  110. pinecall/gateway/calls/inbox.py +100 -0
  111. pinecall/gateway/calls/known.py +23 -0
  112. pinecall/gateway/calls/owners.py +87 -0
  113. pinecall/gateway/calls/pump.py +33 -0
  114. pinecall/gateway/calls/serving.py +118 -0
  115. pinecall/gateway/calls/threads.py +367 -0
  116. pinecall/gateway/dispatching/__init__.py +0 -0
  117. pinecall/gateway/dispatching/arrivals.py +58 -0
  118. pinecall/gateway/dispatching/sweep.py +43 -0
  119. pinecall/gateway/ending/__init__.py +0 -0
  120. pinecall/gateway/ending/reaper.py +160 -0
  121. pinecall/gateway/ending/seal.py +312 -0
  122. pinecall/gateway/ending/stranded.py +117 -0
  123. pinecall/log/__init__.py +0 -0
  124. pinecall/log/_histogram.py +74 -0
  125. pinecall/log/_relay.py +259 -0
  126. pinecall/log/_writer.py +454 -0
  127. pinecall/log/days.py +154 -0
  128. pinecall/log/drift.py +550 -0
  129. pinecall/log/facts.py +358 -0
  130. pinecall/log/inbox.py +169 -0
  131. pinecall/log/lists.py +155 -0
  132. pinecall/log/logs.py +581 -0
  133. pinecall/log/openings.py +71 -0
  134. pinecall/log/private.py +135 -0
  135. pinecall/log/queries.py +229 -0
  136. pinecall/log/readers.py +207 -0
  137. pinecall/log/reduce.py +659 -0
  138. pinecall/log/refold.py +217 -0
  139. pinecall/log/store.py +370 -0
  140. pinecall/log/usage.py +242 -0
  141. pinecall/postgres/__init__.py +0 -0
  142. pinecall/postgres/migrate.py +202 -0
  143. pinecall/postgres/migrations/0001_schema.sql +591 -0
  144. pinecall/postgres/migrations/0002_worlds.sql +15 -0
  145. pinecall/postgres/migrations/0003_lent.sql +4 -0
  146. pinecall/postgres/migrations/0004_carriers.sql +23 -0
  147. pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
  148. pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
  149. pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
  150. pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
  151. pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
  152. pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
  153. pinecall/postgres/migrations/0011_erasure.sql +32 -0
  154. pinecall/postgres/migrations/0012_org_policy.sql +10 -0
  155. pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
  156. pinecall/postgres/migrations/0014_consents.sql +24 -0
  157. pinecall/postgres/migrations/0015_disclosure.sql +7 -0
  158. pinecall/postgres/migrations/0016_call_records.sql +21 -0
  159. pinecall/postgres/migrations/0017_reads.sql +17 -0
  160. pinecall/postgres/migrations/0018_hosting.sql +41 -0
  161. pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
  162. pinecall/postgres/migrations/0020_written_once.sql +7 -0
  163. pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
  164. pinecall/postgres/migrations/0022_sealed.sql +39 -0
  165. pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
  166. pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
  167. pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
  168. pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
  169. pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
  170. pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
  171. pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
  172. pinecall/postgres/migrations/0060_drift.sql +62 -0
  173. pinecall/postgres/migrations/0061_canaries.sql +24 -0
  174. pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
  175. pinecall/postgres/migrations/0063_prompts.sql +16 -0
  176. pinecall/postgres/migrations/0075_call_private.sql +14 -0
  177. pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
  178. pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
  179. pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
  180. pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
  181. pinecall/postgres/migrations/0080_call_openings.sql +20 -0
  182. pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
  183. pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
  184. pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
  185. pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
  186. pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
  187. pinecall/postgres/migrations/0086_route_origin.sql +17 -0
  188. pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
  189. pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
  190. pinecall/postgres/migrations/0089_offers.sql +21 -0
  191. pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
  192. pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
  193. pinecall/postgres/migrations/0092_key_parent.sql +6 -0
  194. pinecall/postgres/migrations/0093_code_tries.sql +10 -0
  195. pinecall/postgres/migrations/0094_route_approval.sql +12 -0
  196. pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
  197. pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
  198. pinecall/postgres/migrations/0097_case_status.sql +19 -0
  199. pinecall/postgres/pool.py +190 -0
  200. pinecall/process/__init__.py +0 -0
  201. pinecall/process/_objects.py +97 -0
  202. pinecall/process/box_settings.py +23 -0
  203. pinecall/process/connections.py +124 -0
  204. pinecall/process/metrics.py +127 -0
  205. pinecall/process/recordings.py +323 -0
  206. pinecall/process/resolver.py +41 -0
  207. pinecall/process/sealed_audio.py +173 -0
  208. pinecall/process/settings.py +534 -0
  209. pinecall/process/shared.py +158 -0
  210. pinecall/process/signal.py +416 -0
  211. pinecall/providers/__init__.py +0 -0
  212. pinecall/providers/build.py +570 -0
  213. pinecall/providers/catalog.py +266 -0
  214. pinecall/providers/credentials.py +230 -0
  215. pinecall/providers/declared.py +69 -0
  216. pinecall/providers/prices.py +240 -0
  217. pinecall/providers/tried.py +104 -0
  218. pinecall/providers/voices.py +174 -0
  219. pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
  220. pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
  221. pinecall/public/console/favicon.png +0 -0
  222. pinecall/public/console/index.html +20 -0
  223. pinecall/public/console/logo-mark.png +0 -0
  224. pinecall/public/console/pinecall-logo-dark.png +0 -0
  225. pinecall/public/console/pinecall-logo.png +0 -0
  226. pinecall/public/console/pinecall-mark.png +0 -0
  227. pinecall/public/console/sw.js +37 -0
  228. pinecall/public/widget/pinecall-widget.js +853 -0
  229. pinecall/py.typed +0 -0
  230. pinecall/retrieval/__init__.py +0 -0
  231. pinecall/retrieval/_search.py +186 -0
  232. pinecall/retrieval/_sections.py +70 -0
  233. pinecall/retrieval/embed.py +340 -0
  234. pinecall/retrieval/extraction.py +403 -0
  235. pinecall/retrieval/knowledge.py +659 -0
  236. pinecall/retrieval/lookups.py +192 -0
  237. pinecall/retrieval/memory.py +603 -0
  238. pinecall/runner/__init__.py +0 -0
  239. pinecall/runner/_kube.py +459 -0
  240. pinecall/runner/_plan.py +157 -0
  241. pinecall/runner/main.py +306 -0
  242. pinecall/session/__init__.py +0 -0
  243. pinecall/session/_agent.py +141 -0
  244. pinecall/session/_hearing.py +101 -0
  245. pinecall/session/_livekit.py +195 -0
  246. pinecall/session/_prompt.py +147 -0
  247. pinecall/session/_recording.py +237 -0
  248. pinecall/session/call.py +367 -0
  249. pinecall/session/clock.py +40 -0
  250. pinecall/session/hold.py +239 -0
  251. pinecall/session/room.py +626 -0
  252. pinecall/session/session.py +690 -0
  253. pinecall/session/text.py +144 -0
  254. pinecall/session/tools.py +483 -0
  255. pinecall/session/voice.py +118 -0
  256. pinecall/session/widget.py +201 -0
  257. pinecall/tenancy/__init__.py +0 -0
  258. pinecall/tenancy/admission.py +204 -0
  259. pinecall/tenancy/agents.py +164 -0
  260. pinecall/tenancy/canary.py +126 -0
  261. pinecall/tenancy/carrier_networks.py +156 -0
  262. pinecall/tenancy/carriers.py +265 -0
  263. pinecall/tenancy/codes.py +196 -0
  264. pinecall/tenancy/consents.py +167 -0
  265. pinecall/tenancy/dial_policy.py +405 -0
  266. pinecall/tenancy/disclosure.py +41 -0
  267. pinecall/tenancy/erasure.py +204 -0
  268. pinecall/tenancy/export.py +167 -0
  269. pinecall/tenancy/hosted_running.py +166 -0
  270. pinecall/tenancy/hosting.py +475 -0
  271. pinecall/tenancy/judges.py +126 -0
  272. pinecall/tenancy/keys.py +467 -0
  273. pinecall/tenancy/knocks.py +40 -0
  274. pinecall/tenancy/letters.py +378 -0
  275. pinecall/tenancy/mail.py +458 -0
  276. pinecall/tenancy/org_secrets.py +123 -0
  277. pinecall/tenancy/orgs.py +78 -0
  278. pinecall/tenancy/people.py +543 -0
  279. pinecall/tenancy/personas.py +145 -0
  280. pinecall/tenancy/policy.py +65 -0
  281. pinecall/tenancy/prompts.py +48 -0
  282. pinecall/tenancy/reads.py +73 -0
  283. pinecall/tenancy/recording_keys.py +41 -0
  284. pinecall/tenancy/remembered.py +148 -0
  285. pinecall/tenancy/retention.py +86 -0
  286. pinecall/tenancy/scopes.py +406 -0
  287. pinecall/tenancy/signin.py +622 -0
  288. pinecall/tenancy/spend.py +69 -0
  289. pinecall/tenancy/sso.py +431 -0
  290. pinecall/tenancy/sso_domains.py +132 -0
  291. pinecall/tenancy/throttle.py +88 -0
  292. pinecall/tenancy/tokens.py +400 -0
  293. pinecall/tenancy/traceback.py +70 -0
  294. pinecall/tenancy/usage.py +136 -0
  295. pinecall/tenancy/vault.py +204 -0
  296. pinecall/tenancy/words.py +144 -0
  297. pinecall/wire/__init__.py +0 -0
  298. pinecall/wire/commands.py +350 -0
  299. pinecall/wire/events.py +655 -0
  300. pinecall/wire/frames.py +65 -0
  301. pinecall/wire/metrics.py +287 -0
  302. pinecall/wire/parts.py +320 -0
  303. pinecall/wire/rest/__init__.py +0 -0
  304. pinecall/wire/rest/accounts.py +512 -0
  305. pinecall/wire/rest/agents.py +172 -0
  306. pinecall/wire/rest/calls.py +397 -0
  307. pinecall/wire/rest/evals.py +453 -0
  308. pinecall/wire/rest/fleet.py +69 -0
  309. pinecall/wire/rest/hosting.py +174 -0
  310. pinecall/wire/rest/numbers.py +280 -0
  311. pinecall/wire/rest/ops.py +342 -0
  312. pinecall/wire/rest/providers.py +83 -0
  313. pinecall/wire/rest/retrieval.py +301 -0
  314. pinecall/wire/rest/settings.py +184 -0
  315. pinecall/wire/rest/usage.py +245 -0
  316. pinecall/wire/room.py +61 -0
  317. pinecall/wire/scores.py +49 -0
  318. pinecall/wire/state.py +243 -0
  319. pinecall/worker/__init__.py +0 -0
  320. pinecall/worker/_job.py +655 -0
  321. pinecall/worker/_recorder.py +82 -0
  322. pinecall/worker/_traces.py +48 -0
  323. pinecall/worker/main.py +307 -0
  324. pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
  325. pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
  326. pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
  327. pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
  328. pinecall_runtime-0.1.6.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,367 @@
1
+ """What a call is while it runs, written once for a voice call and a written one."""
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import logging
6
+ import time
7
+ from collections.abc import Awaitable, Callable
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+
11
+ from livekit.agents.utils import aio
12
+
13
+ from pinecall.domain.agent import DEFAULT_LAYOUT, AgentConfig, EventSource, PromptBlock, ToolSpec
14
+ from pinecall.domain.call import CallContext
15
+ from pinecall.domain.errors import DeclarationRefused, NotAvailable
16
+ from pinecall.domain.names import JsonObject
17
+ from pinecall.session._hearing import policy_for
18
+ from pinecall.wire.commands import CallCallback, CallLog, SessionConfigure, StateSet, ToolsSet
19
+ from pinecall.wire.events import (
20
+ EPHEMERAL_EVENTS,
21
+ AgentConfigured,
22
+ CallbackRequested,
23
+ Custom,
24
+ EventReceived,
25
+ StateCause,
26
+ StateCauseEvent,
27
+ StateChanged,
28
+ ToolsChanged,
29
+ )
30
+ from pinecall.wire.frames import Entry, WireModel
31
+ from pinecall.wire.metrics import ModelUsage
32
+ from pinecall.wire.parts import AgentConfig as Declared
33
+ from pinecall.wire.parts import Contact, PlatformTool, Supervisor, ToolResult
34
+ from pinecall.wire.rest.calls import BatchedEntry
35
+
36
+ # What a session reaches outside the process its call runs in. In the gateway these are the
37
+ # call's own log and the doors behind it; in a worker, the same doors over the fleet's client.
38
+ # `append` is called as `append(kind, data, ephemeral=...)`, as `Log.append` is.
39
+ type Append = Callable[..., Awaitable[Entry]]
40
+
41
+
42
+ # Called as `append_many(entries, after=...)`, as `Log.append_many` is: the entries numbered, in
43
+ # the order they were sent.
44
+ type AppendMany = Callable[..., Awaitable[list[Entry]]]
45
+
46
+
47
+ type Lookup = Callable[[PlatformTool, JsonObject, str | None], Awaitable[JsonObject]]
48
+
49
+
50
+ type Seal = Callable[[list[ModelUsage], str], Awaitable[None]]
51
+
52
+
53
+ logger = logging.getLogger(__name__)
54
+
55
+
56
+ # `sp_<n>`: numbered on from the highest in the log when a call is taken up again.
57
+ SPEECH = "sp_"
58
+
59
+
60
+ # A callback the agent took during a call; others come from the widget or an overflow.
61
+ BY_THE_AGENT = "agent"
62
+
63
+
64
+ UNDECLARED = "event {name!r} is not one this agent declared the {source} may send"
65
+
66
+
67
+ # A limit under two minutes is told at its half instead.
68
+ WARNED_BEFORE_S = 60
69
+
70
+
71
+ CLOSING = (
72
+ "The call reaches its time limit in about a minute. Bring it to a close now: answer what is "
73
+ "pending in a sentence, tell the caller the call has to end soon, and say goodbye."
74
+ )
75
+
76
+
77
+ # What one request carries: whatever is queued when the last one was answered, up to this.
78
+ MOST_A_BATCH = 64
79
+
80
+
81
+ # Past this many waiting, an ephemeral entry is shed: no store keeps it. A durable one is always
82
+ # queued, so a gateway that was away never leaves a hole in the log.
83
+ MOST_QUEUED = 4096
84
+
85
+
86
+ SHED = "call {call}: {queued} entries wait for the log, so {kind} was shed"
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class ToolUse:
91
+ """One tool call the model made: its id, the tool, and the arguments it chose."""
92
+
93
+ call_id: str
94
+ name: str
95
+ arguments: JsonObject
96
+
97
+
98
+ type RunTool = Callable[[ToolUse, str | None], Awaitable[ToolResult]]
99
+
100
+
101
+ class Writing:
102
+ """The call's entries in the order they happened, sent to the log in batches."""
103
+
104
+ # livekit calls most listeners synchronously; one queue drained by one task keeps order. A
105
+ # writer that takes over a call another wrote to follows on from what the log took from it.
106
+ def __init__(self, append_many: AppendMany, call: str, *, after: int = 0) -> None:
107
+ """An empty queue for the call, drained once `open` starts it."""
108
+ self.append_many = append_many
109
+ self.call = call
110
+ self.queued: asyncio.Queue[tuple[BatchedEntry, asyncio.Future[Entry]]] = asyncio.Queue()
111
+ self.draining: asyncio.Task[None] | None = None
112
+ # How many entries the log took from this call's writer: what the next batch follows.
113
+ self.after = after
114
+ self.refused: list[str] = []
115
+ self.shed: list[str] = []
116
+
117
+ def open(self) -> None:
118
+ """Start sending."""
119
+ if self.draining is None:
120
+ self.draining = asyncio.create_task(self._drain())
121
+
122
+ # livekit calls most listeners synchronously: they queue and move on; a caller that needs
123
+ # the entry (its seq) awaits the future. The stamp is when it happened, not when it was sent.
124
+ def write(
125
+ self, kind: str, event: WireModel, *, ephemeral: bool | None = None
126
+ ) -> asyncio.Future[Entry]:
127
+ """Queue an entry; the future is the entry once the log holds it."""
128
+ written: asyncio.Future[Entry] = asyncio.get_running_loop().create_future()
129
+ forgettable = kind in EPHEMERAL_EVENTS if ephemeral is None else ephemeral
130
+ waiting = self.queued.qsize()
131
+ if forgettable and waiting >= MOST_QUEUED:
132
+ self.shed.append(kind)
133
+ _refused(written, NotAvailable(SHED.format(call=self.call, queued=waiting, kind=kind)))
134
+ return written
135
+ entry = BatchedEntry(type=kind, data=event.written(), ephemeral=ephemeral, ts=time.time())
136
+ self.queued.put_nowait((entry, written))
137
+ return written
138
+
139
+ async def flushed(self, within_s: float) -> None:
140
+ """Wait until every queued entry was sent; TimeoutError past `within_s`."""
141
+ await asyncio.wait_for(self.queued.join(), within_s)
142
+
143
+ async def close(self, within_s: float) -> None:
144
+ """Send what is queued for up to `within_s`, then stop and say what never went."""
145
+ try:
146
+ await self.flushed(within_s)
147
+ except TimeoutError:
148
+ logger.warning(
149
+ "call %s: %d entries never reached the log within %gs",
150
+ self.call,
151
+ self.queued.qsize(),
152
+ within_s,
153
+ )
154
+ if self.draining is not None:
155
+ await aio.cancel_and_wait(self.draining)
156
+ self.draining = None
157
+ if self.refused:
158
+ logger.warning(
159
+ "call %s: the log refused %d entries (%s)",
160
+ self.call,
161
+ len(self.refused),
162
+ ", ".join(sorted(set(self.refused))),
163
+ )
164
+ if self.shed:
165
+ logger.warning(
166
+ "call %s: %d ephemeral entries were shed at the queue's ceiling (%s)",
167
+ self.call,
168
+ len(self.shed),
169
+ ", ".join(sorted(set(self.shed))),
170
+ )
171
+
172
+ # No timer: while one batch is out the next one fills, so an idle call sends each entry
173
+ # alone and a busy one batches itself.
174
+ async def _drain(self) -> None:
175
+ while True:
176
+ batch = [await self.queued.get()]
177
+ while len(batch) < MOST_A_BATCH and not self.queued.empty():
178
+ batch.append(self.queued.get_nowait())
179
+ try:
180
+ await self._sent(batch)
181
+ finally:
182
+ for _ in batch:
183
+ self.queued.task_done()
184
+
185
+ # A refused batch never ends the call: the client retries what is transient with the same
186
+ # `after`, so what fails here is a refusal of the whole batch, counted and named at close.
187
+ async def _sent(self, batch: list[tuple[BatchedEntry, asyncio.Future[Entry]]]) -> None:
188
+ kinds = [entry.type for entry, _ in batch]
189
+ try:
190
+ numbered = await self.append_many([entry for entry, _ in batch], after=self.after)
191
+ except asyncio.CancelledError:
192
+ self.refused += kinds
193
+ raise
194
+ except Exception as refused:
195
+ logger.warning(
196
+ "call %s: the log refused a batch of %d", self.call, len(batch), exc_info=True
197
+ )
198
+ self.refused += kinds
199
+ for _, written in batch:
200
+ if not written.done():
201
+ _refused(written, refused)
202
+ return
203
+ self.after += len(batch)
204
+ for (_, written), entry in zip(batch, numbered, strict=True):
205
+ if not written.done():
206
+ written.set_result(entry)
207
+
208
+
209
+ @dataclass(frozen=True)
210
+ class Platform:
211
+ """The log a call writes, the app's tools, the lookups, and the seal at the end."""
212
+
213
+ append_many: AppendMany
214
+ tool: RunTool
215
+ lookup: Lookup
216
+ seal: Seal
217
+
218
+
219
+ class Call:
220
+ """One call's state that outlives a turn, and the entries the app's commands write."""
221
+
222
+ def __init__(
223
+ self,
224
+ context: CallContext,
225
+ config: AgentConfig,
226
+ platform: Platform,
227
+ recording: Path | None = None,
228
+ ) -> None:
229
+ """A call nobody has spoken on yet, every declared tool open."""
230
+ self.context = context
231
+ self.config = config
232
+ # Where its session records its audio: none for a written call or one the agent keeps none.
233
+ self.recording = recording
234
+ self.turn_policy = policy_for(config.language)
235
+ self.platform = platform
236
+ self.writing = Writing(platform.append_many, context.call)
237
+ self.speeches = 0
238
+ self.turns = 0
239
+ self.last_said = ""
240
+ # What the next state.set is answering: a running tool, or an event the app received.
241
+ self.cause: StateCause | None = None
242
+ self.taken_by: Supervisor | None = None
243
+ self.waiting_for_a_person = False
244
+ # As the session's agent_state events say, not as livekit's internals hold it.
245
+ self.agent_speaking = False
246
+ self.open_tools = frozenset(tool.name for tool in config.tools)
247
+ # Every request the model was sent, kept on a call an eval run opened and on no other.
248
+ self.requests: list[JsonObject] = []
249
+
250
+ def speech(self) -> str:
251
+ """A new speech id, `sp_<n>`."""
252
+ self.speeches += 1
253
+ return f"{SPEECH}{self.speeches}"
254
+
255
+ @property
256
+ def a_person_has_the_line(self) -> bool:
257
+ """Whether the model must not answer: a supervisor holds the line, or one is awaited."""
258
+ return self.taken_by is not None or self.waiting_for_a_person
259
+
260
+ async def set_state(self, wanted: StateSet) -> None:
261
+ """The app's whole state, and what it answers when a tool or an event caused it."""
262
+ changed = wanted.changed if wanted.changed is not None else sorted(wanted.state)
263
+ cause, self.cause = self.cause, None
264
+ written = StateChanged(state=wanted.state, changed=changed)
265
+ if cause is not None:
266
+ written.cause = cause
267
+ await self.writing.write("state.changed", written)
268
+
269
+ async def receives(
270
+ self, name: str, data: JsonObject, *, source: EventSource, identity: str | None = None
271
+ ) -> None:
272
+ """An event the agent declared this sender may send, the cause of the next state.set."""
273
+ if not self.config.accepts(name, source):
274
+ raise DeclarationRefused(UNDECLARED.format(name=name, source=source))
275
+ received = EventReceived(name=name, data=data, source=source, identity=identity)
276
+ entry = await self.writing.write("event.received", received)
277
+ self.cause = StateCauseEvent(kind="event", name=name, seq=entry.seq)
278
+
279
+ async def log_custom(self, wanted: CallLog) -> None:
280
+ """A line of the app's own, as it sent it."""
281
+ await self.writing.write("custom", Custom(name=wanted.name, data=wanted.data))
282
+
283
+ async def call_back(self, wanted: CallCallback) -> None:
284
+ """A callback the agent took, in the call's log, where the callbacks door collects it."""
285
+ contact = self.context.contact
286
+ await self.writing.write(
287
+ "callback.requested",
288
+ CallbackRequested(
289
+ channel=self.context.channel,
290
+ number=wanted.number,
291
+ via=BY_THE_AGENT,
292
+ call=self.context.call,
293
+ contact=None if contact is None else Contact.model_validate(asdict(contact)),
294
+ when=wanted.when,
295
+ note=wanted.note,
296
+ ),
297
+ )
298
+
299
+ # What the app sets up before the first turn; a declaration sent here lasts this call only.
300
+ async def configure(self, wanted: SessionConfigure) -> None:
301
+ """The app's declaration and state for this call."""
302
+ if wanted.config is not None:
303
+ self.config = with_app_fields(self.config, wanted.config)
304
+ configured = AgentConfigured(changed=changed_by(wanted.config))
305
+ await self.writing.write("agent.configured", configured)
306
+ if wanted.state is not None:
307
+ await self.set_state(StateSet(state=wanted.state))
308
+
309
+ # Every tool stays declared for the whole call: changing what the provider is sent spends
310
+ # its prompt cache. The subset open now is enforced where a tool is run.
311
+ async def set_tools(self, wanted: ToolsSet) -> None:
312
+ """Open only these of the declared tools; an empty list closes them all."""
313
+ declared = {tool.name for tool in self.config.tools}
314
+ self.open_tools = frozenset(tool.name for tool in wanted.tools if tool.name in declared)
315
+ await self.writing.write("tools.changed", ToolsChanged(visible=sorted(self.open_tools)))
316
+
317
+
318
+ # A declaration is a patch: only the fields sent change. What the org sets per world (the
319
+ # voice, the models, memory, the greeting) comes from its settings and is not taken from here.
320
+ def with_app_fields(current: AgentConfig, declared: Declared) -> AgentConfig:
321
+ """The agent's declaration with the fields the app sent in place."""
322
+ sent = declared.model_fields_set
323
+ changed: dict[str, object] = {}
324
+ if "prompt" in sent:
325
+ changed["prompt"] = (
326
+ tuple(PromptBlock(block.name, block.region) for block in declared.prompt)
327
+ if declared.prompt
328
+ else DEFAULT_LAYOUT
329
+ )
330
+ if "language" in sent:
331
+ changed["language"] = declared.language
332
+ if "uses_knowledge" in sent:
333
+ changed["uses_knowledge"] = declared.uses_knowledge
334
+ if "tools" in sent:
335
+ changed["tools"] = tuple(
336
+ ToolSpec(
337
+ name=tool.name,
338
+ description=tool.description,
339
+ parameters=tool.parameters,
340
+ side_effect=tool.side_effect,
341
+ pii=frozenset(tool.pii or ()),
342
+ confirm=tool.confirm,
343
+ timeout_s=ToolSpec.timeout_s if tool.timeout_s is None else tool.timeout_s,
344
+ )
345
+ for tool in declared.tools or ()
346
+ )
347
+ if "state_fields" in sent:
348
+ changed["state_fields"] = {
349
+ item.name: item.visibility for item in declared.state_fields or ()
350
+ }
351
+ if "view" in sent:
352
+ changed["view"] = None if declared.view is None else declared.view.name
353
+ if "events" in sent:
354
+ changed["events"] = {item.name: frozenset(item.from_) for item in declared.events or ()}
355
+ return dataclasses.replace(current, **changed)
356
+
357
+
358
+ def changed_by(declared: Declared) -> list[str]:
359
+ """The fields a declaration sets, sorted."""
360
+ return sorted(declared.model_fields_set)
361
+
362
+
363
+ # Most entries are queued and never awaited: read here, the refusal is not logged a second time
364
+ # by asyncio as an exception nobody retrieved. `close` names what was refused and what was shed.
365
+ def _refused(written: asyncio.Future[Entry], why: Exception) -> None:
366
+ written.set_exception(why)
367
+ written.exception()
@@ -0,0 +1,40 @@
1
+ """A call's time: its ceiling, warned before it ends, and a chat in a room ended when quiet."""
2
+
3
+ import asyncio
4
+ import time
5
+
6
+ from pinecall.session.call import CLOSING, WARNED_BEFORE_S
7
+ from pinecall.session.session import Session
8
+ from pinecall.wire import events as wire
9
+
10
+
11
+ # The limit holds while a supervisor has the line; only the warning is skipped, since a
12
+ # generated turn would talk over them.
13
+ async def keep_time(
14
+ session: Session, limit_s: int, *, exhausted: wire.CreditsExhausted | None
15
+ ) -> None:
16
+ """Warn the agent before the limit and end the call at it; nothing when there is none."""
17
+ if limit_s == 0:
18
+ return
19
+ warned_at = limit_s - WARNED_BEFORE_S if limit_s >= 2 * WARNED_BEFORE_S else limit_s / 2
20
+ await asyncio.sleep(warned_at)
21
+ if not session.call.a_person_has_the_line:
22
+ session.live.generate_reply(instructions=CLOSING)
23
+ await asyncio.sleep(limit_s - warned_at)
24
+ if exhausted is not None:
25
+ await session.call.writing.write("credits.exhausted", exhausted)
26
+ session.hang_up("timeout", "platform")
27
+
28
+
29
+ # A chat in a room has no ceiling, and a visitor who leaves the page open would hold its seat for
30
+ # ever (one did for over an hour on 2026-10-03): it ends once the person has said nothing for long.
31
+ async def end_when_quiet(session: Session, quiet_s: float) -> None:
32
+ """End the call once the person has said nothing for `quiet_s`; nothing when zero."""
33
+ if quiet_s == 0:
34
+ return
35
+ while True:
36
+ left = quiet_s - (time.monotonic() - session.last_heard)
37
+ if left <= 0:
38
+ session.hang_up("timeout", "platform")
39
+ return
40
+ await asyncio.sleep(left)
@@ -0,0 +1,239 @@
1
+ """Hold music: the clip played into the room while the caller waits, faded in and out."""
2
+
3
+ import asyncio
4
+ import io
5
+ import logging
6
+ import time
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import av
11
+ from av.audio.fifo import AudioFifo
12
+ from av.audio.stream import AudioStream
13
+ from av.container import InputContainer, OutputContainer
14
+ from av.error import FFmpegError
15
+ from livekit import rtc
16
+ from livekit.agents.voice.background_audio import AudioConfig, BackgroundAudioPlayer, PlayHandle
17
+
18
+ from pinecall.domain.errors import DeclarationRefused
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ # The hold melody sits under the agent's voice, since a phone leg has no volume of its own. A
23
+ # tool that answers inside the grace plays nothing.
24
+ VOLUME = 0.6
25
+
26
+ GRACE_S = 2.5
27
+
28
+ FADE_IN_S = 0.4
29
+
30
+ FADE_OUT_S = 0.3
31
+
32
+
33
+ # livekit mixes at 48 kHz; mono like a phone leg; Opus for size, in whole frames only.
34
+ RATE = 48_000
35
+
36
+
37
+ OPUS_FRAME = 960
38
+
39
+
40
+ BIT_RATE = 64_000
41
+
42
+
43
+ # The melody loops, so a long file only costs storage, and a very short one stutters.
44
+ LONGEST_S = 300
45
+
46
+
47
+ SHORTEST_S = 1
48
+
49
+
50
+ NOT_AUDIO = "that file is no audio this box can read: send a wav, an mp3, an ogg or an m4a"
51
+
52
+
53
+ # The containers a melody comes in, each by the bytes it starts with: FFmpeg is told which and
54
+ # probes nothing, so no playlist, script or path a file could carry is followed (an `m3u8` fetches
55
+ # URLs, an `ffconcat` opens files). The pair is (offset, magic) → the demuxer's name.
56
+ CONTAINERS: tuple[tuple[int, bytes, str], ...] = (
57
+ (0, b"RIFF", "wav"),
58
+ (0, b"OggS", "ogg"),
59
+ (0, b"fLaC", "flac"),
60
+ (4, b"ftyp", "mov,mp4,m4a,3gp,3g2,mj2"),
61
+ (0, b"ID3", "mp3"),
62
+ # An MPEG audio frame's sync with layer III: MPEG-1, MPEG-2 and MPEG-2.5, with and without CRC.
63
+ (0, b"\xff\xfb", "mp3"),
64
+ (0, b"\xff\xfa", "mp3"),
65
+ (0, b"\xff\xf3", "mp3"),
66
+ (0, b"\xff\xf2", "mp3"),
67
+ (0, b"\xff\xe3", "mp3"),
68
+ (0, b"\xff\xe2", "mp3"),
69
+ )
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class Converted:
74
+ """An upload as the worker plays it: Ogg Opus, 48 kHz mono, and how long it lasts."""
75
+
76
+ audio: bytes
77
+ seconds: float
78
+
79
+
80
+ class HoldMusic:
81
+ """The melody played while a tool runs or the line is held, shared by what overlaps."""
82
+
83
+ def __init__(self, clip: Path) -> None:
84
+ """A melody not yet in any room."""
85
+ self.clip = clip
86
+ self.player: BackgroundAudioPlayer | None = None
87
+ self.handle: PlayHandle | None = None
88
+ self.running = 0
89
+ self.pending: asyncio.Task[None] | None = None
90
+ self.quiet = asyncio.Event()
91
+ self.quiet.set()
92
+ # When it sounded, by the clock, for the call's recording to lay it in: the player's track
93
+ # is the room's, not the session's, and the clip is already on this disk.
94
+ self.played: list[tuple[float, float]] = []
95
+ self.playing_since: float | None = None
96
+
97
+ # The player publishes its own track, which the SIP bridge mixes into the phone leg. A
98
+ # player that does not start leaves the call without a melody, and nothing else.
99
+ async def start(self, room: rtc.Room) -> None:
100
+ """Publish the melody's track in the room."""
101
+ player = BackgroundAudioPlayer()
102
+ try:
103
+ await player.start(room=room) # pyright: ignore[reportUnknownMemberType]
104
+ except (RuntimeError, ConnectionError):
105
+ logger.warning(
106
+ "the hold melody did not start: the call goes on without it", exc_info=True
107
+ )
108
+ return
109
+ self.player = player
110
+
111
+ def floor(self, *, speaking: bool) -> None:
112
+ """Whether the agent is speaking, as the session says."""
113
+ if speaking:
114
+ self.quiet.clear()
115
+ else:
116
+ self.quiet.set()
117
+
118
+ def began(self) -> None:
119
+ """One more reason to play; the first starts the melody after the grace."""
120
+ self.running += 1
121
+ if self.running == 1 and self.player is not None:
122
+ self.pending = asyncio.create_task(self._after_the_grace())
123
+
124
+ def ended(self) -> None:
125
+ """One reason less; the last stops the melody."""
126
+ self.running = max(self.running - 1, 0)
127
+ if self.running == 0:
128
+ self._stop()
129
+
130
+ # Nothing stops the melody when the caller hangs up on hold: the player goes with the room.
131
+ def sounded(self) -> list[tuple[float, float]]:
132
+ """When the melody played, by the clock; the one still playing counted up to now."""
133
+ if self.playing_since is None:
134
+ return self.played
135
+ return [*self.played, (self.playing_since, time.time())]
136
+
137
+ async def aclose(self) -> None:
138
+ """Stop and close the player."""
139
+ self._stop()
140
+ player, self.player = self.player, None
141
+ if player is not None:
142
+ await player.aclose()
143
+
144
+ # The tool starts while its announcement still plays, so the grace counts from when the agent
145
+ # is quiet; checked again after it, since livekit may speak another round's preamble.
146
+ async def _after_the_grace(self) -> None:
147
+ await self.quiet.wait()
148
+ await asyncio.sleep(GRACE_S)
149
+ if self.running == 0 or not self.quiet.is_set() or self.player is None:
150
+ return
151
+ melody = AudioConfig(str(self.clip), volume=VOLUME, fade_in=FADE_IN_S, fade_out=FADE_OUT_S)
152
+ self.handle = self.player.play(melody, loop=True)
153
+ self.playing_since = time.time()
154
+
155
+ def _stop(self) -> None:
156
+ if self.pending is not None and not self.pending.done():
157
+ self.pending.cancel()
158
+ self.pending = None
159
+ if self.handle is not None:
160
+ self.handle.stop()
161
+ self.handle = None
162
+ if self.playing_since is not None:
163
+ self.played.append((self.playing_since, time.time()))
164
+ self.playing_since = None
165
+
166
+
167
+ TOO_LONG = f"a hold melody loops, so {LONGEST_S // 60} minutes at most: this one runs longer"
168
+
169
+
170
+ TOO_SHORT = f"a hold melody of less than {SHORTEST_S} second would stutter as it loops"
171
+
172
+
173
+ # Converted once, here, so no worker decodes a tenant's upload in the middle of a call.
174
+ def converted(data: bytes) -> Converted:
175
+ """Audio in a container this box names, as the melody the worker plays; refused in words."""
176
+ container = container_of(data)
177
+ if container is None:
178
+ raise DeclarationRefused(NOT_AUDIO)
179
+ try:
180
+ source = av.open(io.BytesIO(data), mode="r", format=container)
181
+ except (FFmpegError, OSError, ValueError) as unreadable:
182
+ raise DeclarationRefused(NOT_AUDIO) from unreadable
183
+ with source:
184
+ if not source.streams.audio:
185
+ raise DeclarationRefused(NOT_AUDIO)
186
+ try:
187
+ audio, samples = _encoded(source)
188
+ except (FFmpegError, OSError, ValueError) as unreadable:
189
+ raise DeclarationRefused(NOT_AUDIO) from unreadable
190
+ seconds = samples / RATE
191
+ if seconds < SHORTEST_S:
192
+ raise DeclarationRefused(TOO_SHORT)
193
+ return Converted(audio=audio, seconds=round(seconds, 2))
194
+
195
+
196
+ def container_of(data: bytes) -> str | None:
197
+ """The demuxer the file's first bytes name; None for a container this box does not read."""
198
+ for offset, magic, named in CONTAINERS:
199
+ if data[offset : offset + len(magic)] == magic:
200
+ return named
201
+ return None
202
+
203
+
204
+ # Opus takes whole frames only: a tail shorter than one is dropped.
205
+ def drained(fifo: AudioFifo, stream: AudioStream, sink: OutputContainer, *, final: bool) -> None:
206
+ """Encode what the fifo holds in whole Opus frames; at the end, the tail that is one."""
207
+ while fifo.samples >= OPUS_FRAME or (final and fifo.samples > 0):
208
+ frame = fifo.read(min(OPUS_FRAME, fifo.samples))
209
+ if frame is None or frame.samples < OPUS_FRAME:
210
+ return
211
+ for packet in stream.encode(frame): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
212
+ sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
213
+
214
+
215
+ def _encoded(source: InputContainer) -> tuple[bytes, int]:
216
+ out = io.BytesIO()
217
+ resampler = av.AudioResampler(format="s16", layout="mono", rate=RATE)
218
+ fifo = av.AudioFifo()
219
+ samples = 0
220
+ with av.open(out, mode="w", format="ogg") as sink:
221
+ stream: AudioStream = sink.add_stream("libopus", rate=RATE) # pyright: ignore[reportUnknownMemberType]
222
+ stream.layout = "mono"
223
+ stream.bit_rate = BIT_RATE
224
+ for decoded in source.decode(audio=0):
225
+ for frame in resampler.resample(decoded):
226
+ samples += frame.samples
227
+ if samples > LONGEST_S * RATE:
228
+ raise DeclarationRefused(TOO_LONG)
229
+ frame.pts = None
230
+ fifo.write(frame)
231
+ drained(fifo, stream, sink, final=False)
232
+ for frame in resampler.resample(None):
233
+ samples += frame.samples
234
+ frame.pts = None
235
+ fifo.write(frame)
236
+ drained(fifo, stream, sink, final=True)
237
+ for packet in stream.encode(None): # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
238
+ sink.mux(packet) # pyright: ignore[reportUnknownMemberType]
239
+ return out.getvalue(), samples