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,182 @@
1
+ """Written calls on the gateway: opened on the socket that holds the agent, or taken up again."""
2
+
3
+ from collections.abc import Iterable
4
+ from dataclasses import dataclass, replace
5
+ from datetime import UTC, date, datetime
6
+
7
+ from pinecall.domain.agent import AgentConfig, Versions
8
+ from pinecall.domain.call import CallContext, Route, today_in
9
+ from pinecall.domain.errors import Conflict
10
+ from pinecall.domain.names import CHANNELS_WITH_A_NUMBER, JsonObject
11
+ from pinecall.domain.scope import Scope
12
+ from pinecall.evals import goldens
13
+ from pinecall.gateway._call_setup import keys_of, tuned
14
+ from pinecall.gateway._served import Served, Serving
15
+ from pinecall.gateway._sockets import Registration
16
+ from pinecall.gateway.calls.binding import attach
17
+ from pinecall.gateway.calls.serving import looked_up, served_call
18
+ from pinecall.gateway.ending.seal import sealed
19
+ from pinecall.log import openings, private, queries
20
+ from pinecall.log.store import Claim
21
+ from pinecall.providers import catalog
22
+ from pinecall.providers.build import Running
23
+ from pinecall.providers.credentials import thinking
24
+ from pinecall.session import text
25
+ from pinecall.session.call import Call, Platform
26
+ from pinecall.session.session import Session
27
+ from pinecall.session.text import text_session
28
+ from pinecall.tenancy import admission
29
+ from pinecall.tenancy.scopes import Picked
30
+ from pinecall.wire.events import (
31
+ TERMINAL_EVENT,
32
+ CallStarted,
33
+ )
34
+ from pinecall.wire.frames import Entry
35
+ from pinecall.wire.metrics import LLMModelUsage, ModelUsage
36
+ from pinecall.wire.parts import PlatformTool
37
+ from pinecall.wire.rest.calls import LookupRequest, SealCallRequest
38
+
39
+ # A written call's session runs on one gateway, which says so: another takes it up only once that
40
+ # gateway fell silent.
41
+ RUNS_ELSEWHERE = "call {call} runs on another gateway: it is taken up there"
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class TextSetup:
46
+ """What a written call runs on: the tuned config, its versions, the model, a golden's facts."""
47
+
48
+ config: AgentConfig
49
+ versions: Versions
50
+ model: Running
51
+ # A golden's facts, which recall answers instead of the contact's memory; None on a real call.
52
+ recalled: tuple[str, ...] | None = None
53
+
54
+
55
+ async def open_text(serving: Serving, registration: Registration, context: CallContext) -> Session:
56
+ """A new written call on the socket that holds the agent, admitted and unstarted."""
57
+ pool, scope = serving.connections.pool, registration.scope
58
+ configured = await catalog.providers(pool)
59
+ config, versions = await tuned(
60
+ pool, registration.config, scope, configured, Picked(call=context.call)
61
+ )
62
+ model = thinking(config, configured, await keys_of(pool, serving.connections.vault, scope))
63
+ return await open_text_as(serving, registration, context, TextSetup(config, versions, model))
64
+
65
+
66
+ # Admission before the log is claimed: a refused call leaves nothing behind.
67
+ async def open_text_as(
68
+ serving: Serving, registration: Registration, context: CallContext, setup: TextSetup
69
+ ) -> Session:
70
+ """A new written call on this setup, admitted and unstarted."""
71
+ pool, scope = serving.connections.pool, registration.scope
72
+ await admission.admit_call(
73
+ pool,
74
+ scope.org,
75
+ scope.env,
76
+ running=serving.live.running(scope.org, scope.env),
77
+ at=serving.logs.store.clock(),
78
+ )
79
+ await serving.logs.store.claim(
80
+ context.call,
81
+ registration.slug,
82
+ scope.org,
83
+ Claim(scope, setup.versions, opened_by="gateway"),
84
+ )
85
+ # Kept as a phone call's is: the gateway holding the app's socket serves it from this.
86
+ await openings.kept(pool, scope.org, context, setup.config)
87
+ await serving.prompts.keep(pool, scope.org, setup.config.knowledge or "")
88
+ served = served_call(serving, registration.owner, context, setup.config, scope)
89
+ serving.live.opened_by(context.call, "gateway")
90
+ await serving.live.commands_heard(context.call)
91
+ return _session(serving, served, setup.model, setup.recalled)
92
+
93
+
94
+ # A gateway that restarted forgot the call, not the caller: no admission, no second greeting.
95
+ async def resume_text(
96
+ serving: Serving, registration: Registration, call: str, zone: str
97
+ ) -> Session | None:
98
+ """A written call taken up again from its log; None for one that is over or not this agent's."""
99
+ kept = await queries.scope_of_call(serving.connections.pool, call)
100
+ if kept is None or kept.sealed or kept.scope is None or kept.agent != registration.slug:
101
+ return None
102
+ if serving.live.owners.text_elsewhere(call):
103
+ raise Conflict(RUNS_ELSEWHERE.format(call=call))
104
+ if kept.scope.org != registration.scope.org:
105
+ return None
106
+ # The model reads the tools' arguments as they were sent: the private ones opened.
107
+ entries = await private.opened_whole(serving.logs.store, serving.connections.vault, call)
108
+ if not entries or any(entry.type == TERMINAL_EVENT for entry in entries):
109
+ return None
110
+ configured = await catalog.providers(serving.connections.pool)
111
+ config, _ = await tuned(
112
+ serving.connections.pool, registration.config, kept.scope, configured, Picked(call=call)
113
+ )
114
+ model = thinking(
115
+ config,
116
+ configured,
117
+ await keys_of(serving.connections.pool, serving.connections.vault, kept.scope),
118
+ )
119
+ context = _as_it_opened(call, registration, kept.scope, entries, today_in(zone))
120
+ served = served_call(serving, None, context, config, kept.scope)
121
+ serving.live.opened_by(call, kept.opened_by)
122
+ await serving.live.commands_heard(call)
123
+ session = _session(serving, served, model, None)
124
+ # The session's batches go on from what the log already took from it, never from zero.
125
+ session.call.writing.after = await serving.logs.store.written(call)
126
+ await text.resume(session, text.taken_up(entries))
127
+ await attach(serving.live, call, registration.owner)
128
+ return session
129
+
130
+
131
+ def tokens_of(usage: Iterable[ModelUsage]) -> int:
132
+ """The model tokens a call used so far: the llm_tokens quota counts these."""
133
+ return sum(
134
+ (used.input_tokens or 0) + (used.output_tokens or 0)
135
+ for used in usage
136
+ if isinstance(used, LLMModelUsage)
137
+ )
138
+
139
+
140
+ def _session(
141
+ serving: Serving, served: Served, model: Running, recalled: tuple[str, ...] | None
142
+ ) -> Session:
143
+ async def lookup(tool: PlatformTool, arguments: JsonObject, speech: str | None) -> JsonObject:
144
+ request = LookupRequest(tool=tool, input=arguments, speech_id=speech)
145
+ return await looked_up(serving, served, request)
146
+
147
+ async def seal(usage: list[ModelUsage], outcome: str) -> None:
148
+ await sealed(serving, served, SealCallRequest(usage=usage, outcome=outcome))
149
+
150
+ looking = lookup if recalled is None else goldens.golden_lookup(recalled, lookup)
151
+ platform = Platform(
152
+ append_many=served.log.append_many, tool=served.tools.ran, lookup=looking, seal=seal
153
+ )
154
+ session = text_session(Call(served.context, served.config, platform), model)
155
+ serving.live.calls[served.call] = replace(served, session=session)
156
+ serving.live.owners.running(served.call, here=True)
157
+ return session
158
+
159
+
160
+ def _as_it_opened(
161
+ call: str, registration: Registration, scope: Scope, entries: Iterable[Entry], today: date
162
+ ) -> CallContext:
163
+ started = next((entry for entry in entries if entry.type == "call.started"), None)
164
+ data = None if started is None else CallStarted.model_validate(started.data)
165
+ caller = "" if data is None else (data.from_ or "")
166
+ # A WhatsApp conversation comes back on its number; a chat at none.
167
+ channel = "web" if data is None else data.channel
168
+ number = data.to if data is not None and channel in CHANNELS_WITH_A_NUMBER else None
169
+ route = Route(
170
+ org=scope.org, agent=registration.slug, channel=channel, number=number, env=scope.env
171
+ )
172
+ if data is not None:
173
+ today = datetime.fromtimestamp(data.started_at, tz=UTC).date()
174
+ return CallContext(
175
+ call=call,
176
+ channel=channel,
177
+ direction="inbound",
178
+ caller=caller or call,
179
+ route=route,
180
+ today=today,
181
+ holder=scope.holder or None,
182
+ )
File without changes
@@ -0,0 +1,324 @@
1
+ """Who a key is, and how a person gets one: sign-in, codes, the org switch, a paired terminal."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from fastapi import APIRouter, Request, Response
6
+
7
+ from pinecall.domain.errors import Conflict, DeclarationRefused, NotAllowed, NotFound, NotSignedIn
8
+ from pinecall.domain.names import DISTRIBUTION, PRODUCTION
9
+ from pinecall.domain.person import Member
10
+ from pinecall.gateway._deps import (
11
+ ActingDep,
12
+ BearerDep,
13
+ GatewayDep,
14
+ check_knock,
15
+ client_of,
16
+ public_url,
17
+ )
18
+ from pinecall.postgres.pool import box_wide
19
+ from pinecall.tenancy import keys, letters, mail, orgs, people, signin
20
+ from pinecall.tenancy.signin import Asking
21
+ from pinecall.wire.rest.accounts import (
22
+ AcceptInvitationRequest,
23
+ BrandRow,
24
+ EmptyResponse,
25
+ FirstKeyResponse,
26
+ ForgotPasswordRequest,
27
+ GatewayInfoResponse,
28
+ KeyCollectedResponse,
29
+ KeyIssuedResponse,
30
+ MemberRow,
31
+ MintCodeResponse,
32
+ OpenPairingRequest,
33
+ OrgOfPersonRow,
34
+ OrgsOfPersonResponse,
35
+ PairingApprovedResponse,
36
+ PairingStatusResponse,
37
+ SignInOrgRow,
38
+ SignInOrgsRequest,
39
+ SignInOrgsResponse,
40
+ SignInRequest,
41
+ SwitchOrgRequest,
42
+ WhoamiResponse,
43
+ )
44
+
45
+ router = APIRouter()
46
+
47
+
48
+ ONE_OR_THE_OTHER = "log in with org, email and password, or with a code: one of the two"
49
+
50
+
51
+ # One sentence for a code spent, dead or never minted.
52
+ NO_CODE = "no code answers to that: it was used, it expired, or it never existed"
53
+
54
+
55
+ # One sentence for a word collected, dead or never printed, so nobody probes which existed.
56
+ NO_PAIRING = "no pairing answers to that: it was collected, it expired, or it never existed"
57
+
58
+
59
+ ANSWERED = "that terminal is already signed in: it has a key waiting"
60
+
61
+
62
+ NOT_A_PERSON = "an org's own key names nobody: a terminal is signed in as a person"
63
+
64
+
65
+ NO_INVITATION = (
66
+ "no open invitation answers to that token: it was used, it expired, or it never existed"
67
+ )
68
+
69
+
70
+ # What a listed org says of a person of the box who is no member there.
71
+ AS_THE_OPERATOR = "operator"
72
+
73
+
74
+ FIRST_KEY = "invitation"
75
+
76
+
77
+ @router.get("/.well-known/pinecall")
78
+ async def gateway_info(gateway: GatewayDep) -> GatewayInfoResponse:
79
+ """What this gateway is and how it signs people in, before anybody holds a key."""
80
+ connections = gateway.connections
81
+ box_mail = await mail.box_mail_of(
82
+ connections.pool, connections.vault, gateway.outbox.environment
83
+ )
84
+ brand = await letters.brand_of(connections.pool)
85
+ return GatewayInfoResponse(
86
+ version=version(DISTRIBUTION),
87
+ signup=connections.settings.signup,
88
+ min_password=connections.settings.min_password,
89
+ mail=box_mail is not None,
90
+ brand=BrandRow(name=brand.name, logo_url=brand.logo_url, accent=brand.accent),
91
+ google=False,
92
+ )
93
+
94
+
95
+ @router.get("/v1/whoami")
96
+ async def whoami(key: ActingDep, gateway: GatewayDep) -> WhoamiResponse:
97
+ """Whose key this is, the world this request acts in, and what the key opens."""
98
+ minted, member = key.bearer.key, key.bearer.member
99
+ org = await orgs.find(gateway.connections.pool, minted.org)
100
+ return WhoamiResponse(
101
+ org=minted.org,
102
+ slug=None if org is None else org.slug,
103
+ key_id=minted.key_id,
104
+ label=minted.label,
105
+ env=key.env,
106
+ scopes=sorted(minted.scopes),
107
+ subject=minted.subject,
108
+ name=minted.name,
109
+ email=None if member is None else member.email,
110
+ operator=member is not None and member.operator,
111
+ visiting=member is not None and member.org != minted.org,
112
+ production=minted.env == PRODUCTION if member is None else member.opens_production,
113
+ )
114
+
115
+
116
+ # The body says a password or a code; each is its own sign-in, and a body with both is refused.
117
+ @router.post("/v1/login")
118
+ async def sign_in(body: SignInRequest, request: Request, gateway: GatewayDep) -> KeyIssuedResponse:
119
+ """A key for a person and a device, from a password, or from a one-use code."""
120
+ pool = gateway.connections.pool
121
+ if body.code is not None:
122
+ if body.org is not None or body.email is not None or body.password is not None:
123
+ raise DeclarationRefused(ONE_OR_THE_OTHER)
124
+ signed = await signin.sign_in_with_code(
125
+ pool, gateway.signins.codes, body.code, device=body.device
126
+ )
127
+ if signed is None:
128
+ raise NotFound(NO_CODE)
129
+ return KeyIssuedResponse.of(signed.key, signed.secret)
130
+ if body.email is None or body.password is None:
131
+ raise DeclarationRefused(ONE_OR_THE_OTHER)
132
+ knock = f"{client_of(request)} {body.org or '*'}/{body.email}"
133
+ await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
134
+ typed = Asking(body.email, body.password, body.org, body.device)
135
+ signed = await signin.sign_in_with_password(pool, typed)
136
+ return KeyIssuedResponse.of(signed.key, signed.secret)
137
+
138
+
139
+ # Throttled as the login is, and refused in its sentence, so a wrong password says nothing more.
140
+ @router.post("/v1/login/orgs")
141
+ async def sign_in_orgs(
142
+ body: SignInOrgsRequest, request: Request, gateway: GatewayDep
143
+ ) -> SignInOrgsResponse:
144
+ """The orgs an address and a password open, minting nothing."""
145
+ pool = gateway.connections.pool
146
+ knock = f"{client_of(request)} */{body.email}"
147
+ await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
148
+ opened = await signin.orgs_signed_into(pool, body.email, body.password)
149
+ if not opened:
150
+ raise NotSignedIn(signin.NOBODY)
151
+ rows: list[SignInOrgRow] = []
152
+ for row in opened:
153
+ org = await orgs.find(pool, row.org)
154
+ if org is not None:
155
+ rows.append(SignInOrgRow(org=org.id, slug=org.slug, name=org.name, role=row.role))
156
+ return SignInOrgsResponse(orgs=rows)
157
+
158
+
159
+ # A person of the box also sees every org there is, to visit without taking a seat.
160
+ @router.get("/v1/login/orgs")
161
+ async def list_orgs_of_person(key: BearerDep, gateway: GatewayDep) -> OrgsOfPersonResponse:
162
+ """Every org this key's person may open, oldest first, and which one this key opens."""
163
+ pool = gateway.connections.pool
164
+ person = _person_of(key.member)
165
+ rows: list[OrgOfPersonRow] = []
166
+ # The person's other orgs are other orgs: read past the key's own (postgres/pool.py).
167
+ with box_wide():
168
+ for row in await people.orgs_of(pool, person.email):
169
+ if row.status == "disabled":
170
+ continue
171
+ org = await orgs.find(pool, row.org)
172
+ rows.append(
173
+ OrgOfPersonRow(
174
+ org=row.org,
175
+ slug=None if org is None else org.slug,
176
+ name=None if org is None else org.name,
177
+ role=row.role,
178
+ status=row.status,
179
+ here=row.org == key.key.org,
180
+ member=True,
181
+ )
182
+ )
183
+ if not person.operator:
184
+ return OrgsOfPersonResponse(orgs=rows)
185
+ theirs = {row.org for row in rows}
186
+ for org in await orgs.listed(pool):
187
+ if org.id in theirs:
188
+ continue
189
+ rows.append(
190
+ OrgOfPersonRow(
191
+ org=org.id,
192
+ slug=org.slug,
193
+ name=org.name,
194
+ role=AS_THE_OPERATOR,
195
+ status="active",
196
+ here=org.id == key.key.org,
197
+ member=False,
198
+ )
199
+ )
200
+ return OrgsOfPersonResponse(orgs=rows)
201
+
202
+
203
+ @router.post("/v1/login/org")
204
+ async def switch_org(
205
+ body: SwitchOrgRequest, key: BearerDep, gateway: GatewayDep
206
+ ) -> KeyIssuedResponse:
207
+ """The same person's key in another org of theirs, or a visit for a person of the box."""
208
+ pool = gateway.connections.pool
209
+ person = _person_of(key.member)
210
+ # The org asked for is another org, and the key minted is written in it.
211
+ with box_wide():
212
+ org = await orgs.find(pool, body.org)
213
+ if org is None:
214
+ raise NotAllowed(signin.NOT_THEIRS.format(org=body.org))
215
+ signed = await signin.key_in(pool, person, org.id, label=key.key.label)
216
+ return KeyIssuedResponse.of(signed.key, signed.secret)
217
+
218
+
219
+ # A browser signs in with the code, so a key never rides a URL.
220
+ @router.post("/v1/login/codes")
221
+ async def mint_code(key: BearerDep, gateway: GatewayDep) -> MintCodeResponse:
222
+ """A one-use word that gives a browser a key like this one, for five minutes."""
223
+ code, expires_at = await gateway.signins.codes.mint(key.key)
224
+ return MintCodeResponse(code=code, expires_at=expires_at)
225
+
226
+
227
+ # 202 whoever asks, and throttled like the login, so the door says nothing about who exists.
228
+ @router.post("/v1/login/reset", status_code=202)
229
+ async def forget_password(
230
+ body: ForgotPasswordRequest, request: Request, gateway: GatewayDep
231
+ ) -> EmptyResponse:
232
+ """Mail a one-use link that sets the password, where one can go."""
233
+ knock = f"{client_of(request)} */{body.email}"
234
+ await check_knock(gateway, knock, signin.TOO_MANY.format(email=body.email))
235
+ await signin.forgotten(
236
+ gateway.connections.pool, gateway.outbox, body.email, public_url(request, gateway)
237
+ )
238
+ return EmptyResponse()
239
+
240
+
241
+ @router.post("/v1/login/pairings")
242
+ async def open_pairing(body: OpenPairingRequest, gateway: GatewayDep) -> MintCodeResponse:
243
+ """The word a terminal prints for a browser to approve, and when it dies."""
244
+ code, expires_at = await gateway.signins.pairings.open(body.device)
245
+ return MintCodeResponse(code=code, expires_at=expires_at)
246
+
247
+
248
+ # Reading it does not spend it, so the approving page can be reloaded.
249
+ @router.get("/v1/login/pairings/{code}")
250
+ async def pairing_status(code: str, gateway: GatewayDep) -> PairingStatusResponse:
251
+ """Which terminal a browser is about to sign in, and whether it is answered."""
252
+ waiting = await gateway.signins.pairings.asking(code)
253
+ if waiting is None:
254
+ raise NotFound(NO_PAIRING)
255
+ return PairingStatusResponse(
256
+ device=waiting.device, expires_at=waiting.expires_at, answered=waiting.answered
257
+ )
258
+
259
+
260
+ # The password is typed in the browser, never the terminal, so this works with SSO too; the
261
+ # terminal gets a key of its own, never the browser's.
262
+ @router.post("/v1/login/pairings/{code}")
263
+ async def approve_pairing(
264
+ code: str, key: BearerDep, gateway: GatewayDep
265
+ ) -> PairingApprovedResponse:
266
+ """Sign the terminal in as the person this browser is."""
267
+ if key.key.subject is None or key.member is None:
268
+ raise NotAllowed(NOT_A_PERSON)
269
+ pairings = gateway.signins.pairings
270
+ waiting = await pairings.asking(code)
271
+ if waiting is None:
272
+ raise NotFound(NO_PAIRING)
273
+ if waiting.answered:
274
+ raise Conflict(ANSWERED)
275
+ signed = await signin.another_key(
276
+ gateway.connections.pool, key.key, key.member, device=waiting.device
277
+ )
278
+ if not await pairings.fill(code, signed.secret, signed.key.org):
279
+ raise Conflict(ANSWERED)
280
+ org = await orgs.find(gateway.connections.pool, signed.key.org)
281
+ return PairingApprovedResponse(
282
+ device=waiting.device,
283
+ org=signed.key.org,
284
+ org_name=signed.key.org if org is None else org.name,
285
+ )
286
+
287
+
288
+ # 202 is "ask again"; 404 is a word gone, and an empty 200 would read as a key of "".
289
+ @router.get("/v1/login/pairings/{code}/key")
290
+ async def collect_key(
291
+ code: str, response: Response, gateway: GatewayDep
292
+ ) -> KeyCollectedResponse | EmptyResponse:
293
+ """The terminal's key, once; 202 while nobody has approved."""
294
+ collected = await gateway.signins.pairings.collect(code)
295
+ if collected.key is not None:
296
+ return KeyCollectedResponse(key=collected.key)
297
+ if not collected.waiting:
298
+ raise NotFound(NO_PAIRING)
299
+ response.status_code = 202
300
+ return EmptyResponse()
301
+
302
+
303
+ # No key: the week-long, one-use token is the credential. An invitation seats the person with the
304
+ # password they have, or their first one; a reset link sets it again.
305
+ @router.post("/v1/invitations/{token}")
306
+ async def accept_invitation(
307
+ token: str, body: AcceptInvitationRequest, gateway: GatewayDep
308
+ ) -> FirstKeyResponse:
309
+ """Type the password: the member is active, and here is their first key."""
310
+ pool = gateway.connections.pool
311
+ shortest = gateway.connections.settings.min_password
312
+ people.check_password(body.password, shortest)
313
+ member = await people.accept(pool, token, body.password, shortest)
314
+ if member is None:
315
+ raise NotFound(NO_INVITATION)
316
+ minted, secret = await keys.person_key(pool, member, label=body.device or FIRST_KEY)
317
+ issued = KeyIssuedResponse.of(minted, secret)
318
+ return FirstKeyResponse(**issued.model_dump(), member=MemberRow.of(member))
319
+
320
+
321
+ def _person_of(member: Member | None) -> Member:
322
+ if member is None:
323
+ raise NotAllowed(signin.NOT_A_PERSONS)
324
+ return member
@@ -0,0 +1,150 @@
1
+ """What a worker asks about an agent, and the agents and routes the org holds."""
2
+
3
+ import dataclasses
4
+ import time
5
+ from typing import Annotated
6
+
7
+ from fastapi import APIRouter, Query, Response
8
+ from pydantic import BaseModel
9
+
10
+ from pinecall.channels import routes
11
+ from pinecall.domain.agent import AgentConfig
12
+ from pinecall.domain.call import Route
13
+ from pinecall.domain.errors import (
14
+ NotFound,
15
+ )
16
+ from pinecall.domain.names import THE_WIDGET, Channel
17
+ from pinecall.domain.person import THE_FLEET
18
+ from pinecall.domain.scope import Scope
19
+ from pinecall.gateway import _deps
20
+ from pinecall.gateway._call_setup import keys_of, tuned
21
+ from pinecall.gateway._deps import CallsKey, DispatchedDep, GatewayDep, ScopeDep, WorkerKey
22
+ from pinecall.gateway._gateway import Gateway
23
+ from pinecall.gateway._sockets import NO_AGENT, Registration
24
+ from pinecall.providers import catalog
25
+ from pinecall.providers.credentials import Pipeline, pipeline
26
+ from pinecall.tenancy import agents, keys
27
+ from pinecall.tenancy.scopes import Picked
28
+ from pinecall.wire.rest.agents import (
29
+ AgentList,
30
+ AgentRow,
31
+ HoldAudio,
32
+ )
33
+
34
+ router = APIRouter()
35
+
36
+
37
+ # Every agent answers on the widget; no route row is needed for it.
38
+ ON_THE_WEB: frozenset[Channel] = frozenset({THE_WIDGET})
39
+
40
+
41
+ class RouteQuery(BaseModel):
42
+ """A worker asking for the route a number rings, across every org."""
43
+
44
+ number: str | None = None
45
+ channel: Channel = "phone"
46
+
47
+
48
+ # A worker names the call it asks for before opening it (`for_call`: `call` would ask to act in an
49
+ # opened call's scope), so a canary's share is picked by the call's id; a worker that names none
50
+ # (an older one) is answered the version every call off the canary runs.
51
+ @router.get("/v1/agents/{slug}/config")
52
+ async def agent_config(
53
+ slug: str,
54
+ _key: _deps.DeclarationKey,
55
+ where: ScopeDep,
56
+ gateway: GatewayDep,
57
+ for_call: Annotated[str | None, Query()] = None,
58
+ ) -> AgentConfig:
59
+ """The agent as the scope runs it, for the call named: its declaration under the settings."""
60
+ found = _registration_of(gateway, where, slug)
61
+ configured = await catalog.providers(gateway.connections.pool)
62
+ tuned_config, _ = await tuned(
63
+ gateway.connections.pool, found.config, where, configured, Picked(call=for_call)
64
+ )
65
+ return tuned_config
66
+
67
+
68
+ # The one answer that carries keys: to the fleet's key, or the org's own worker's. The box's
69
+ # keys leave it only for its own workers: an org's process is handed the org's keys alone.
70
+ @router.get("/v1/agents/{slug}/provider-keys")
71
+ async def agent_credentials(
72
+ slug: str,
73
+ key: WorkerKey,
74
+ where: ScopeDep,
75
+ gateway: GatewayDep,
76
+ for_call: Annotated[str | None, Query()] = None,
77
+ ) -> Pipeline:
78
+ """The three stages a call of the agent runs, each on the key it runs on."""
79
+ found = _registration_of(gateway, where, slug)
80
+ configured = await catalog.providers(gateway.connections.pool)
81
+ tuned_config, _ = await tuned(
82
+ gateway.connections.pool, found.config, where, configured, Picked(call=for_call)
83
+ )
84
+ keys = await keys_of(gateway.connections.pool, gateway.connections.vault, where)
85
+ if THE_FLEET not in key.bearer.key.scopes:
86
+ keys = dataclasses.replace(keys, lends=frozenset())
87
+ now = time.monotonic()
88
+ stages = pipeline(tuned_config, configured, keys).demoting(gateway.counters.failing(now))
89
+ gateway.counters.handed_out((stages.llm.vendor, stages.stt.vendor, stages.tts.vendor), now)
90
+ return stages
91
+
92
+
93
+ @router.get("/v1/agents/{slug}/hold-audio")
94
+ async def hold_audio(slug: str, _key: WorkerKey, where: ScopeDep, gateway: GatewayDep) -> HoldAudio:
95
+ """What a caller of the agent hears while a tool runs."""
96
+ chosen = await agents.hold_of(gateway.connections.pool, where, slug)
97
+ if chosen is None:
98
+ return HoldAudio(played="default")
99
+ return HoldAudio(
100
+ played=chosen.played, sha256=chosen.sha256, seconds=chosen.seconds, name=chosen.name
101
+ )
102
+
103
+
104
+ @router.get("/v1/agents/{slug}/hold-audio/audio")
105
+ async def hold_clip(slug: str, _key: WorkerKey, where: ScopeDep, gateway: GatewayDep) -> Response:
106
+ """The org's own clip, Ogg Opus."""
107
+ audio = await agents.hold_audio(gateway.connections.pool, where, slug)
108
+ if audio is None:
109
+ raise NotFound(f"agent {slug} plays no clip of its own")
110
+ return Response(audio, media_type="audio/ogg")
111
+
112
+
113
+ # A route the operator adds answers the next job: nothing is cached.
114
+ @router.get("/v1/routes")
115
+ async def list_routes(
116
+ key: WorkerKey,
117
+ named: DispatchedDep,
118
+ gateway: GatewayDep,
119
+ query: Annotated[RouteQuery, Query()],
120
+ ) -> list[Route]:
121
+ """The routes of a scope; for the fleet's key and a number, the route that number rings."""
122
+ fleet = THE_FLEET in key.bearer.key.scopes
123
+ if fleet and query.number is not None:
124
+ found = await routes.at(gateway.connections.pool, query.channel, query.number)
125
+ return [] if found is None or found.env != key.bearer.key.env else [found]
126
+ where = named if fleet and named is not None else keys.scope_of(key.bearer, key.env)
127
+ return await routes.of_org(gateway.connections.pool, where.org, where.env)
128
+
129
+
130
+ @router.get("/v1/agents")
131
+ async def list_agents(key: CallsKey, where: ScopeDep, gateway: GatewayDep) -> AgentList:
132
+ """The org's held agents in the world: one row per slug, one per scope for a team reader."""
133
+ every = _deps.sees_every_scope(key)
134
+ doors: dict[str, set[Channel]] = {}
135
+ for route in await routes.of_org(gateway.connections.pool, where.org, where.env):
136
+ doors.setdefault(route.agent, set()).add(route.channel)
137
+ listed: list[AgentRow] = []
138
+ for registration in gateway.sockets.holding(where, every_corner=every):
139
+ channels = sorted(doors.get(registration.slug, set[Channel]()) | ON_THE_WEB)
140
+ holder = await _deps.named_holder(gateway, registration.scope)
141
+ listed.append(AgentRow(slug=registration.slug, channels=channels, holder=holder))
142
+ return AgentList(agents=listed)
143
+
144
+
145
+ # Another org's agent is the same 404 as nobody's: its existence does not leak.
146
+ def _registration_of(gateway: Gateway, where: Scope, slug: str) -> Registration:
147
+ registration = gateway.sockets.of(where, slug)
148
+ if registration is None or registration.scope.org != where.org:
149
+ raise NotFound(NO_AGENT.format(slug=slug))
150
+ return registration