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,478 @@
1
+ """The app socket a tenant's process holds, and the org's list of connected apps."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import time
6
+
7
+ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
8
+ from pydantic import ValidationError
9
+
10
+ from pinecall.domain.errors import (
11
+ DeclarationRefused,
12
+ NotAllowed,
13
+ NotAvailable,
14
+ NotFound,
15
+ PinecallError,
16
+ QuotaExhausted,
17
+ )
18
+ from pinecall.domain.names import Json
19
+ from pinecall.domain.person import HOLDING
20
+ from pinecall.gateway import _deps
21
+ from pinecall.gateway._call_setup import exhausted, tuned
22
+ from pinecall.gateway._deps import Acting, AppKey, CallsKey, GatewayDep, ScopeDep
23
+ from pinecall.gateway._gateway import Gateway
24
+ from pinecall.gateway._served import Served, declared_for_the_call
25
+ from pinecall.gateway._sockets import (
26
+ NOT_REGISTERED,
27
+ Process,
28
+ Registration,
29
+ SocketId,
30
+ new_socket_id,
31
+ )
32
+ from pinecall.gateway.calls.binding import handed_on, parked_calls_of
33
+ from pinecall.gateway.calls.commands import commanded
34
+ from pinecall.gateway.calls.inbox import APP_CHANNEL, Bound, ForApp
35
+ from pinecall.gateway.calls.known import known_here
36
+ from pinecall.gateway.calls.serving import claim_code
37
+ from pinecall.providers import catalog
38
+ from pinecall.session.call import changed_by, with_app_fields
39
+ from pinecall.session.tools import unanswered
40
+ from pinecall.tenancy import admission, consents, keys
41
+ from pinecall.tenancy.consents import Given
42
+ from pinecall.wire.commands import (
43
+ COMMANDS,
44
+ AgentConfigure,
45
+ AgentDrain,
46
+ AgentRegister,
47
+ CallClaim,
48
+ CallDial,
49
+ CallOptOut,
50
+ DevAnswer,
51
+ Ping,
52
+ PromptSet,
53
+ command_of,
54
+ )
55
+ from pinecall.wire.events import ErrorEvent, Pong
56
+ from pinecall.wire.frames import Command, Entry, WireModel
57
+ from pinecall.wire.parts import ToolResult
58
+ from pinecall.wire.rest.agents import (
59
+ AppList,
60
+ AppRow,
61
+ StopAppResponse,
62
+ )
63
+
64
+ router = APIRouter()
65
+
66
+
67
+ logger = logging.getLogger(__name__)
68
+
69
+
70
+ # Sent on a stop: the SDK exits instead of reconnecting.
71
+ STOPPED = "stopped"
72
+
73
+
74
+ REFUSED = "refused"
75
+
76
+
77
+ DIAL = (
78
+ "call.dial is not answered on the app socket: POST /v1/agents/{slug}/dial places a call, with "
79
+ "the talk scope and the org's outbound guards"
80
+ )
81
+
82
+
83
+ NO_SESSION = "{kind} names call {call!r}, which is not running here"
84
+
85
+
86
+ NOT_JSON = "a frame is one JSON object: this one did not parse"
87
+
88
+
89
+ # Where an opt-out the agent heard came from, as the do-not-call list says it.
90
+ ASKED_THE_AGENT = "the caller asked the agent"
91
+
92
+ NO_NUMBER_TO_LIST = "call {call} has no phone number at its far end: nothing to put on the list"
93
+
94
+
95
+ NOBODY_WAITING = (
96
+ "no tool call {call_id} is waiting on call {call!r}: it lapsed, it was answered, or the call "
97
+ "is not here"
98
+ )
99
+
100
+
101
+ NOBODY_ASKED = (
102
+ "no dev.request {id} is waiting: it lapsed, it was answered, or another gateway asked it"
103
+ )
104
+
105
+
106
+ NO_CODE = (
107
+ "no code {code} is waiting for agent {agent}: nobody issued it, it expired, or a call took it"
108
+ )
109
+
110
+
111
+ SEARCHES_WITH_NOTHING = (
112
+ "{slug} searches its bases, and none is attached to it in {world}: "
113
+ "pinecall docs attach <base> --agent {slug}"
114
+ )
115
+
116
+
117
+ NO_SUCH_APP = "no app {app} is connected to this box that this key may stop"
118
+
119
+
120
+ # How long a socket waits for the signal before listening again for the calls bound to it.
121
+ BOUND_RETRY_S = 1.0
122
+
123
+
124
+ class AppSocket:
125
+ """One connected app: its key, its scope, and every frame it sends answered."""
126
+
127
+ def __init__(self, websocket: WebSocket, gateway: Gateway, key: Acting) -> None:
128
+ """A socket that holds nothing yet."""
129
+ self.websocket = websocket
130
+ self.gateway = gateway
131
+ self.key = key
132
+ self.scope = keys.scope_of(key.bearer, key.env)
133
+ self.id = new_socket_id()
134
+
135
+ async def serve(self) -> None:
136
+ """Read frames until the app goes; every frame is answered and none raises."""
137
+ while True:
138
+ try:
139
+ raw: Json = await self.websocket.receive_json()
140
+ except ValueError:
141
+ await self.refuse("", "bad_shape", NOT_JSON, None)
142
+ continue
143
+ await self.take(raw)
144
+
145
+ async def send(self, entry: Entry) -> None:
146
+ """One entry, as the log holds it."""
147
+ await self.websocket.send_json(entry.written())
148
+
149
+ async def stopped(self, why: str) -> None:
150
+ """Tell the app it was stopped, and close: it exits instead of reconnecting."""
151
+ data = ErrorEvent(code=STOPPED, message=why, recoverable=False)
152
+ await self.send(_deps.ephemeral_entry("", data))
153
+ await self.websocket.close(reason=_deps.close_reason(why))
154
+
155
+ async def take(self, raw: Json) -> None:
156
+ """One frame: its command applied, or refused in an error that names it."""
157
+ try:
158
+ command = Command.model_validate(raw)
159
+ except ValidationError as refused:
160
+ await self.refuse(_named(raw, "agent"), "bad_shape", f"command: {refused}", raw)
161
+ return
162
+ if command.type not in COMMANDS:
163
+ text = f"the protocol has no command called {command.type!r}"
164
+ await self.refuse(command.agent, "unknown_command", text, raw)
165
+ return
166
+ try:
167
+ await self._applied(command)
168
+ except QuotaExhausted as refused:
169
+ await self.refuse(command.agent, REFUSED, str(refused), raw)
170
+ except PinecallError as refused:
171
+ code = "bad_shape" if isinstance(refused, DeclarationRefused) else REFUSED
172
+ await self.refuse(command.agent, code, str(refused), raw)
173
+
174
+ # One arm per command, as the reducer folds events; what is about a call goes to the call.
175
+ async def _applied(self, command: Command) -> None:
176
+ model = command_of(command)
177
+ match model:
178
+ case AgentRegister():
179
+ await self._register(command.agent, model)
180
+ case AgentConfigure():
181
+ await self._configure(command.agent, model)
182
+ case AgentDrain():
183
+ await self._drain(command.agent)
184
+ case Ping():
185
+ await self._emitted(command.agent, "pong", Pong(ts=time.time()))
186
+ case DevAnswer():
187
+ await self._dev_answered(command, model)
188
+ case ToolResult():
189
+ await self._answered(command, model)
190
+ case CallClaim() | CallOptOut():
191
+ await self._on_the_gateway(command, model)
192
+ case CallDial():
193
+ await self.refuse(command.agent, "no_handler", DIAL, command.written())
194
+ case PromptSet():
195
+ await self._prompted(command, model)
196
+ case _:
197
+ await self._on_the_call(command, model)
198
+
199
+ async def _register(self, slug: str, wanted: AgentRegister) -> None:
200
+ keys.check_agent(self.key.bearer, slug)
201
+ self.gateway.live.named(self.id, wanted.host)
202
+ scope = self.scope
203
+ others = self.gateway.sockets.slugs(scope.org) - {slug}
204
+ try:
205
+ await admission.admit_agent(
206
+ self.gateway.connections.pool, scope.org, scope.env, holding=len(others)
207
+ )
208
+ except QuotaExhausted as refused:
209
+ await exhausted(self.gateway.logs, scope.org, slug, refused)
210
+ raise
211
+ entry = await self.gateway.sockets.register(self.id, scope, slug, wanted)
212
+ await self.send(entry)
213
+ # A console takes no call it did not open; an app takes the calls a previous one left.
214
+ if wanted.takes_unclaimed:
215
+ await parked_calls_of(self.gateway.live, scope, slug, self.id)
216
+
217
+ # A class that searches with no base attached is refused when declared, not mid-call.
218
+ async def _configure(self, slug: str, wanted: AgentConfigure) -> None:
219
+ found = self._holds(slug)
220
+ config = with_app_fields(found.config, wanted.config)
221
+ configured = await catalog.providers(self.gateway.connections.pool)
222
+ running, _ = await tuned(self.gateway.connections.pool, config, self.scope, configured)
223
+ if config.uses_knowledge and not running.bases:
224
+ raise DeclarationRefused(SEARCHES_WITH_NOTHING.format(slug=slug, world=self.scope.env))
225
+ entry = await self.gateway.sockets.configure(
226
+ self.id, self.scope.env, slug, config, changed_by(wanted.config)
227
+ )
228
+ await self.send(entry)
229
+ # Declared now: what waited on WhatsApp for this agent is answered on this declaration.
230
+ if found.takes_unclaimed:
231
+ self.gateway.threads.answering(self._holds(slug))
232
+
233
+ # A deploy: the calls move now, and the socket keeps the agent so its tools still answer.
234
+ async def _drain(self, slug: str) -> None:
235
+ sockets, live = self.gateway.sockets, self.gateway.live
236
+ sockets.drain(self.id, self.scope.env, slug)
237
+ mine = [call for call in live.bound_to(self.id) if live.calls[call].agent == slug]
238
+ handed, parked = await handed_on(live, sockets, mine)
239
+ entry = await sockets.drained(self.id, self.scope.env, slug, handed=handed, parked=parked)
240
+ await self.send(entry)
241
+
242
+ # The worker may have asked another gateway: the answer is written on the call's log here,
243
+ # once per call id, and whoever waits reads it there.
244
+ async def _answered(self, command: Command, result: ToolResult) -> None:
245
+ self._holds(command.agent)
246
+ served = None if command.call is None else self.gateway.live.calls.get(command.call)
247
+ ours = served is not None and served.scope.org == self.scope.org
248
+ if (
249
+ served is not None
250
+ and ours
251
+ and served.agent == command.agent
252
+ and await _written(served, result)
253
+ ):
254
+ return
255
+ text = NOBODY_WAITING.format(call_id=result.call_id, call=command.call)
256
+ await self.refuse(command.agent, "no_session", text, command.written())
257
+
258
+ # Bound by the gateway whichever process runs the call.
259
+ # A code claimed and an opt-out are the gateway's to answer, whichever process runs the call.
260
+ async def _on_the_gateway(self, command: Command, wanted: CallClaim | CallOptOut) -> None:
261
+ self._holds(command.agent)
262
+ served = None if command.call is None else self.gateway.live.calls.get(command.call)
263
+ if served is None or served.agent != command.agent:
264
+ text = NO_SESSION.format(kind=command.type, call=command.call)
265
+ await self.refuse(command.agent, "no_session", text, command.written())
266
+ return
267
+ if isinstance(wanted, CallOptOut):
268
+ await _opted_out(self.gateway, served, command.agent, wanted)
269
+ return
270
+ if not await claim_code(self.gateway.codes, served, wanted.code, via="agent"):
271
+ text = NO_CODE.format(code=wanted.code, agent=command.agent)
272
+ await self.refuse(command.agent, "no_code", text, command.written())
273
+
274
+ async def _dev_answered(self, command: Command, answer: DevAnswer) -> None:
275
+ if not await self.gateway.live.dev_answered(answer):
276
+ text = NOBODY_ASKED.format(id=answer.id)
277
+ await self.refuse(command.agent, "no_session", text, command.written())
278
+
279
+ # The block's words are kept once under the hash the log will name, before the call hears it.
280
+ async def _prompted(self, command: Command, wanted: PromptSet) -> None:
281
+ self._holds(command.agent)
282
+ pool = self.gateway.connections.pool
283
+ await self.gateway.serving.prompts.keep(pool, self.scope.org, wanted.text)
284
+ await self._on_the_call(command, wanted)
285
+
286
+ # A written call runs here and takes the command at once; a voice call's worker reads it
287
+ # off its command stream and applies it with the same function.
288
+ async def _on_the_call(self, command: Command, model: WireModel) -> None:
289
+ self._holds(command.agent)
290
+ served = None if command.call is None else self.gateway.live.calls.get(command.call)
291
+ if served is not None and served.agent == command.agent and served.session is not None:
292
+ declared_for_the_call(served, model)
293
+ await served.session.apply(model)
294
+ return
295
+ if command.call is not None and await commanded(self.gateway.connections.signal, command):
296
+ return
297
+ text = NO_SESSION.format(kind=command.type, call=command.call)
298
+ await self.refuse(command.agent, "no_session", text, command.written())
299
+
300
+ def _holds(self, slug: str) -> Registration:
301
+ registration = self.gateway.sockets.on(self.id, self.scope.env, slug)
302
+ if registration is None:
303
+ raise DeclarationRefused(NOT_REGISTERED.format(slug=slug))
304
+ return registration
305
+
306
+ # On the agent's log only when this socket holds the agent: a slug is one org's, and a frame
307
+ # naming any other is answered down the socket alone, so nothing of it lands on a log.
308
+ async def _emitted(self, slug: str, kind: str, event: WireModel) -> None:
309
+ if self.gateway.sockets.on(self.id, self.scope.env, slug) is None:
310
+ await self.send(_deps.ephemeral_entry(slug, event, kind=kind))
311
+ return
312
+ entry = await self.gateway.logs.agent(slug).append(kind, event.written())
313
+ await self.send(entry)
314
+
315
+ async def refuse(self, slug: str, code: str, message: str, raw: Json) -> None:
316
+ """An error naming the command refused and its id; on the agent's log when it holds one."""
317
+ text = ErrorEvent(
318
+ code=code,
319
+ message=message,
320
+ command=_named(raw, "type") or None,
321
+ id=_named(raw, "id") or None,
322
+ recoverable=True,
323
+ )
324
+ await self._emitted(slug, "error", text)
325
+
326
+
327
+ @router.websocket("/v1/apps")
328
+ async def apps_socket(websocket: WebSocket) -> None:
329
+ """An app holds its agents here, answers their tools, and sends their calls' commands."""
330
+ gateway = _deps.gateway_of(websocket)
331
+ try:
332
+ key = await _socket_key(websocket, gateway)
333
+ except PinecallError as refused:
334
+ await websocket.accept()
335
+ await websocket.close(code=_deps.POLICY_VIOLATION, reason=_deps.close_reason(str(refused)))
336
+ return
337
+ await websocket.accept()
338
+ socket = AppSocket(websocket, gateway, key)
339
+ client = websocket.client
340
+ gateway.live.connect(
341
+ Process(
342
+ app=socket.id,
343
+ scope=socket.scope,
344
+ address=None if client is None else client.host,
345
+ connected_at=time.time(),
346
+ stop=socket.stopped,
347
+ ),
348
+ socket.send,
349
+ )
350
+ bound = asyncio.create_task(_inbox(gateway, socket))
351
+ try:
352
+ await socket.serve()
353
+ except WebSocketDisconnect:
354
+ logger.info("app %s went away", socket.id)
355
+ finally:
356
+ bound.cancel()
357
+ await asyncio.gather(bound, return_exceptions=True)
358
+ # Calls outlive their socket: parked, then handed to another holder or the next app.
359
+ gateway.live.disconnect(socket.id)
360
+ calls = gateway.live.bound_to(socket.id)
361
+ for call in calls:
362
+ gateway.live.attach(call, None)
363
+ await gateway.sockets.release(socket.id)
364
+ await handed_on(gateway.live, gateway.sockets, calls)
365
+
366
+
367
+ @router.get("/v1/apps")
368
+ async def list_apps(key: CallsKey, where: ScopeDep, gateway: GatewayDep) -> AppList:
369
+ """The app sockets of the org in the world this key may see, oldest first."""
370
+ every = _deps.sees_every_scope(key)
371
+ listed: list[AppRow] = []
372
+ for process in gateway.live.processes_of(where.org, where.env):
373
+ if not every and process.scope.holder not in ("", where.holder):
374
+ continue
375
+ registration = gateway.sockets.owned_by(process.app)
376
+ listed.append(
377
+ AppRow(
378
+ app=process.app,
379
+ agents=[holder.slug for holder in registration],
380
+ env=process.scope.env,
381
+ host=process.host,
382
+ address=process.address,
383
+ sdk=next((holder.sdk for holder in registration if holder.sdk is not None), None),
384
+ holder=await _deps.named_holder(gateway, process.scope),
385
+ connected_at=process.connected_at,
386
+ )
387
+ )
388
+ return AppList(apps=listed)
389
+
390
+
391
+ # One 404 for every reason (another org, world or scope, gone): nothing leaks.
392
+ @router.post("/v1/apps/{app}/stop")
393
+ async def stop_app(app: str, key: AppKey, where: ScopeDep, gateway: GatewayDep) -> StopAppResponse:
394
+ """Tell the app it was stopped, and close its socket, on whichever gateway holds it."""
395
+ connected = gateway.live.processes_of(where.org, where.env)
396
+ found = next((process for process in connected if process.app == app), None)
397
+ every = _deps.sees_every_scope(key)
398
+ if found is None:
399
+ raise NotFound(NO_SUCH_APP.format(app=app))
400
+ if not every and found.scope.holder not in ("", where.holder):
401
+ raise NotFound(NO_SUCH_APP.format(app=app))
402
+ who = key.bearer.key.name or key.bearer.key.label or "a server of the org"
403
+ await found.stop(f"stopped by {who}")
404
+ return StopAppResponse(app=app, stopped=True)
405
+
406
+
407
+ async def _socket_key(websocket: WebSocket, gateway: Gateway) -> Acting:
408
+ data = _deps.bearer_of(websocket.headers)
409
+ verified = None if data is None else await gateway.keys.verify(data)
410
+ if verified is None:
411
+ raise NotAllowed(_deps.TAKES_A_KEY)
412
+ keys.check_opens(verified, HOLDING)
413
+ return Acting(bearer=verified, env=_deps.world_of_request(websocket, verified))
414
+
415
+
416
+ def _named(raw: Json, field: str) -> str:
417
+ if not isinstance(raw, dict):
418
+ return ""
419
+ named = raw.get(field)
420
+ return named if isinstance(named, str) else ""
421
+
422
+
423
+ # What the call's log masks follows a declaration sent for this call alone, before the call
424
+ # writes the state it declares, whichever process runs it.
425
+ # The list is the org's, not the call's, and nothing lands in the log: an SDK that predates the
426
+ # command would refuse to read an entry it has no shape for.
427
+ async def _opted_out(gateway: Gateway, served: Served, agent: str, wanted: CallOptOut) -> None:
428
+ if served.context.channel != "phone":
429
+ raise DeclarationRefused(NO_NUMBER_TO_LIST.format(call=served.call))
430
+ given = Given(
431
+ kind="opt_out",
432
+ source=ASKED_THE_AGENT,
433
+ given_by=f"agent:{agent}",
434
+ text=wanted.note,
435
+ call=served.call,
436
+ )
437
+ await consents.give(gateway.connections.pool, served.scope, served.context.caller, given)
438
+
439
+
440
+ async def _written(served: Served, result: ToolResult) -> bool:
441
+ """Hand the answer to the round trip waiting here, else write it once on the call's log."""
442
+ return served.tools.answered(result) or await served.log.answer(result.written()) is not None
443
+
444
+
445
+ # What other gateways send this socket: a call they bound to it (served here and pumped down it,
446
+ # from the seq they said), an entry for it (a console's dev.request), or a stop.
447
+ async def _inbox(gateway: Gateway, socket: AppSocket) -> None:
448
+ signal = gateway.connections.signal
449
+ while True:
450
+ try:
451
+ listening = await signal.subscribe(APP_CHANNEL.format(app=socket.id))
452
+ except NotAvailable:
453
+ await asyncio.sleep(BOUND_RETRY_S)
454
+ continue
455
+ try:
456
+ async for data in listening:
457
+ await _for_the_app(gateway, socket, ForApp.model_validate_json(data))
458
+ finally:
459
+ listening.close()
460
+ await asyncio.sleep(BOUND_RETRY_S)
461
+
462
+
463
+ async def _for_the_app(gateway: Gateway, socket: AppSocket, message: ForApp) -> None:
464
+ if message.bound is not None:
465
+ await _taken_here(gateway, socket.id, message.bound)
466
+ if message.entry is not None:
467
+ await socket.send(message.entry)
468
+ if message.stop is not None:
469
+ await socket.stopped(message.stop)
470
+
471
+
472
+ async def _taken_here(gateway: Gateway, app: SocketId, bound: Bound) -> None:
473
+ served = await known_here(gateway.serving, bound.call)
474
+ if served is None or app not in gateway.live.sockets or bound.call in gateway.live.pumps:
475
+ return
476
+ gateway.live.attach(bound.call, app)
477
+ then = unanswered(await served.log.whole()) if bound.after else []
478
+ gateway.live.pumped(bound.call, after=bound.after, then=then)