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.
- pinecall/__init__.py +0 -0
- pinecall/channels/__init__.py +0 -0
- pinecall/channels/_chooser.py +45 -0
- pinecall/channels/offers.py +219 -0
- pinecall/channels/rooms.py +176 -0
- pinecall/channels/routes.py +275 -0
- pinecall/channels/telephony/__init__.py +0 -0
- pinecall/channels/telephony/_twilio.py +349 -0
- pinecall/channels/telephony/carrier.py +229 -0
- pinecall/channels/telephony/carrier_catalog.py +92 -0
- pinecall/channels/telephony/carriers.csv +41 -0
- pinecall/channels/telephony/dialing.py +419 -0
- pinecall/channels/telephony/firewall.py +58 -0
- pinecall/channels/telephony/hand_over.py +78 -0
- pinecall/channels/telephony/number_path.py +176 -0
- pinecall/channels/telephony/numbers.py +530 -0
- pinecall/channels/telephony/origination.py +77 -0
- pinecall/channels/telephony/sip.py +448 -0
- pinecall/channels/whatsapp.py +413 -0
- pinecall/cli/__init__.py +0 -0
- pinecall/cli/_archive.py +85 -0
- pinecall/cli/_drift.py +38 -0
- pinecall/cli/_facts.py +67 -0
- pinecall/cli/_load.py +433 -0
- pinecall/cli/_operator.py +626 -0
- pinecall/cli/_sessions.py +164 -0
- pinecall/cli/_traceback.py +95 -0
- pinecall/cli/_usage.py +34 -0
- pinecall/cli/main.py +521 -0
- pinecall/domain/__init__.py +0 -0
- pinecall/domain/agent.py +446 -0
- pinecall/domain/call.py +152 -0
- pinecall/domain/country_codes.py +32 -0
- pinecall/domain/errors.py +151 -0
- pinecall/domain/names.py +115 -0
- pinecall/domain/org.py +120 -0
- pinecall/domain/person.py +208 -0
- pinecall/domain/scope.py +21 -0
- pinecall/evals/__init__.py +0 -0
- pinecall/evals/_evidence.py +158 -0
- pinecall/evals/calibration.py +112 -0
- pinecall/evals/callers.py +160 -0
- pinecall/evals/case.py +327 -0
- pinecall/evals/checks.py +437 -0
- pinecall/evals/compliance.py +197 -0
- pinecall/evals/dataset.py +451 -0
- pinecall/evals/goldens.py +331 -0
- pinecall/evals/judges.py +525 -0
- pinecall/evals/runs.py +252 -0
- pinecall/evals/spoken.py +356 -0
- pinecall/fleet/__init__.py +0 -0
- pinecall/fleet/client.py +569 -0
- pinecall/fleet/demand.py +51 -0
- pinecall/fleet/heartbeat.py +194 -0
- pinecall/fleet/measures.py +157 -0
- pinecall/fleet/roster.py +232 -0
- pinecall/fleet/worlds.py +44 -0
- pinecall/gateway/__init__.py +0 -0
- pinecall/gateway/_call_setup.py +53 -0
- pinecall/gateway/_deps.py +556 -0
- pinecall/gateway/_gateway.py +75 -0
- pinecall/gateway/_served.py +396 -0
- pinecall/gateway/_sockets.py +444 -0
- pinecall/gateway/_streams.py +83 -0
- pinecall/gateway/_text_calls.py +182 -0
- pinecall/gateway/api/__init__.py +0 -0
- pinecall/gateway/api/accounts.py +324 -0
- pinecall/gateway/api/agents.py +150 -0
- pinecall/gateway/api/apps.py +478 -0
- pinecall/gateway/api/box.py +658 -0
- pinecall/gateway/api/calibration.py +58 -0
- pinecall/gateway/api/callbacks.py +105 -0
- pinecall/gateway/api/calls.py +655 -0
- pinecall/gateway/api/chat.py +223 -0
- pinecall/gateway/api/dataset.py +163 -0
- pinecall/gateway/api/desk.py +113 -0
- pinecall/gateway/api/entries.py +77 -0
- pinecall/gateway/api/evals.py +647 -0
- pinecall/gateway/api/fleet.py +73 -0
- pinecall/gateway/api/hosting.py +241 -0
- pinecall/gateway/api/judges.py +97 -0
- pinecall/gateway/api/keys.py +110 -0
- pinecall/gateway/api/line.py +142 -0
- pinecall/gateway/api/members.py +197 -0
- pinecall/gateway/api/metrics.py +226 -0
- pinecall/gateway/api/numbers.py +476 -0
- pinecall/gateway/api/ops.py +428 -0
- pinecall/gateway/api/org.py +283 -0
- pinecall/gateway/api/personas.py +157 -0
- pinecall/gateway/api/pipeline.py +206 -0
- pinecall/gateway/api/prompts.py +50 -0
- pinecall/gateway/api/providers.py +240 -0
- pinecall/gateway/api/recordings.py +89 -0
- pinecall/gateway/api/relay.py +169 -0
- pinecall/gateway/api/retrieval.py +403 -0
- pinecall/gateway/api/runner.py +109 -0
- pinecall/gateway/api/sessions.py +102 -0
- pinecall/gateway/api/settings.py +463 -0
- pinecall/gateway/api/signup.py +164 -0
- pinecall/gateway/api/sso_login.py +168 -0
- pinecall/gateway/api/threads.py +187 -0
- pinecall/gateway/api/usage.py +303 -0
- pinecall/gateway/api/visitors.py +222 -0
- pinecall/gateway/api/whatsapp.py +48 -0
- pinecall/gateway/api/widget.py +49 -0
- pinecall/gateway/app.py +504 -0
- pinecall/gateway/calls/__init__.py +0 -0
- pinecall/gateway/calls/binding.py +63 -0
- pinecall/gateway/calls/commands.py +18 -0
- pinecall/gateway/calls/inbox.py +100 -0
- pinecall/gateway/calls/known.py +23 -0
- pinecall/gateway/calls/owners.py +87 -0
- pinecall/gateway/calls/pump.py +33 -0
- pinecall/gateway/calls/serving.py +118 -0
- pinecall/gateway/calls/threads.py +367 -0
- pinecall/gateway/dispatching/__init__.py +0 -0
- pinecall/gateway/dispatching/arrivals.py +58 -0
- pinecall/gateway/dispatching/sweep.py +43 -0
- pinecall/gateway/ending/__init__.py +0 -0
- pinecall/gateway/ending/reaper.py +160 -0
- pinecall/gateway/ending/seal.py +312 -0
- pinecall/gateway/ending/stranded.py +117 -0
- pinecall/log/__init__.py +0 -0
- pinecall/log/_histogram.py +74 -0
- pinecall/log/_relay.py +259 -0
- pinecall/log/_writer.py +454 -0
- pinecall/log/days.py +154 -0
- pinecall/log/drift.py +550 -0
- pinecall/log/facts.py +358 -0
- pinecall/log/inbox.py +169 -0
- pinecall/log/lists.py +155 -0
- pinecall/log/logs.py +581 -0
- pinecall/log/openings.py +71 -0
- pinecall/log/private.py +135 -0
- pinecall/log/queries.py +229 -0
- pinecall/log/readers.py +207 -0
- pinecall/log/reduce.py +659 -0
- pinecall/log/refold.py +217 -0
- pinecall/log/store.py +370 -0
- pinecall/log/usage.py +242 -0
- pinecall/postgres/__init__.py +0 -0
- pinecall/postgres/migrate.py +202 -0
- pinecall/postgres/migrations/0001_schema.sql +591 -0
- pinecall/postgres/migrations/0002_worlds.sql +15 -0
- pinecall/postgres/migrations/0003_lent.sql +4 -0
- pinecall/postgres/migrations/0004_carriers.sql +23 -0
- pinecall/postgres/migrations/0005_retrieved_only.sql +11 -0
- pinecall/postgres/migrations/0006_persona_agents.sql +9 -0
- pinecall/postgres/migrations/0007_money_in_dollars.sql +27 -0
- pinecall/postgres/migrations/0008_lexicon_per_agent.sql +27 -0
- pinecall/postgres/migrations/0009_personas_per_agent.sql +31 -0
- pinecall/postgres/migrations/0010_agent_judges.sql +12 -0
- pinecall/postgres/migrations/0011_erasure.sql +32 -0
- pinecall/postgres/migrations/0012_org_policy.sql +10 -0
- pinecall/postgres/migrations/0013_calling_rules.sql +12 -0
- pinecall/postgres/migrations/0014_consents.sql +24 -0
- pinecall/postgres/migrations/0015_disclosure.sql +7 -0
- pinecall/postgres/migrations/0016_call_records.sql +21 -0
- pinecall/postgres/migrations/0017_reads.sql +17 -0
- pinecall/postgres/migrations/0018_hosting.sql +41 -0
- pinecall/postgres/migrations/0019_hosted_state.sql +10 -0
- pinecall/postgres/migrations/0020_written_once.sql +7 -0
- pinecall/postgres/migrations/0021_hosted_running.sql +21 -0
- pinecall/postgres/migrations/0022_sealed.sql +39 -0
- pinecall/postgres/migrations/0023_usage_totals.sql +158 -0
- pinecall/postgres/migrations/0024_log_key_by_day.sql +8 -0
- pinecall/postgres/migrations/0025_log_before_the_days.sql +15 -0
- pinecall/postgres/migrations/0026_log_before_the_days_validated.sql +4 -0
- pinecall/postgres/migrations/0027_log_by_day.sql +96 -0
- pinecall/postgres/migrations/0030_whatsapp_seen.sql +17 -0
- pinecall/postgres/migrations/0040_reads_widened.sql +15 -0
- pinecall/postgres/migrations/0060_drift.sql +62 -0
- pinecall/postgres/migrations/0061_canaries.sql +24 -0
- pinecall/postgres/migrations/0062_eval_cases.sql +23 -0
- pinecall/postgres/migrations/0063_prompts.sql +16 -0
- pinecall/postgres/migrations/0075_call_private.sql +14 -0
- pinecall/postgres/migrations/0076_recording_keys.sql +13 -0
- pinecall/postgres/migrations/0077_tokens_world.sql +7 -0
- pinecall/postgres/migrations/0078_facts_cost_by_stage.sql +12 -0
- pinecall/postgres/migrations/0079_judge_labels.sql +20 -0
- pinecall/postgres/migrations/0080_call_openings.sql +20 -0
- pinecall/postgres/migrations/0081_seal_lease.sql +7 -0
- pinecall/postgres/migrations/0082_caller_codes.sql +14 -0
- pinecall/postgres/migrations/0083_one_use_words.sql +15 -0
- pinecall/postgres/migrations/0084_knocks_and_run_leases.sql +23 -0
- pinecall/postgres/migrations/0085_seal_lease_holder.sql +8 -0
- pinecall/postgres/migrations/0086_route_origin.sql +17 -0
- pinecall/postgres/migrations/0087_carrier_networks.sql +20 -0
- pinecall/postgres/migrations/0088_route_last_call.sql +3 -0
- pinecall/postgres/migrations/0089_offers.sql +21 -0
- pinecall/postgres/migrations/0090_invitation_purpose.sql +5 -0
- pinecall/postgres/migrations/0091_head_opened_by.sql +6 -0
- pinecall/postgres/migrations/0092_key_parent.sql +6 -0
- pinecall/postgres/migrations/0093_code_tries.sql +10 -0
- pinecall/postgres/migrations/0094_route_approval.sql +12 -0
- pinecall/postgres/migrations/0095_sso_domain_proofs.sql +17 -0
- pinecall/postgres/migrations/0096_row_level_security.sql +38 -0
- pinecall/postgres/migrations/0097_case_status.sql +19 -0
- pinecall/postgres/pool.py +190 -0
- pinecall/process/__init__.py +0 -0
- pinecall/process/_objects.py +97 -0
- pinecall/process/box_settings.py +23 -0
- pinecall/process/connections.py +124 -0
- pinecall/process/metrics.py +127 -0
- pinecall/process/recordings.py +323 -0
- pinecall/process/resolver.py +41 -0
- pinecall/process/sealed_audio.py +173 -0
- pinecall/process/settings.py +534 -0
- pinecall/process/shared.py +158 -0
- pinecall/process/signal.py +416 -0
- pinecall/providers/__init__.py +0 -0
- pinecall/providers/build.py +570 -0
- pinecall/providers/catalog.py +266 -0
- pinecall/providers/credentials.py +230 -0
- pinecall/providers/declared.py +69 -0
- pinecall/providers/prices.py +240 -0
- pinecall/providers/tried.py +104 -0
- pinecall/providers/voices.py +174 -0
- pinecall/public/console/assets/index-BIfKPSgn.js +164 -0
- pinecall/public/console/assets/index-ClOTU-xX.css +1 -0
- pinecall/public/console/favicon.png +0 -0
- pinecall/public/console/index.html +20 -0
- pinecall/public/console/logo-mark.png +0 -0
- pinecall/public/console/pinecall-logo-dark.png +0 -0
- pinecall/public/console/pinecall-logo.png +0 -0
- pinecall/public/console/pinecall-mark.png +0 -0
- pinecall/public/console/sw.js +37 -0
- pinecall/public/widget/pinecall-widget.js +853 -0
- pinecall/py.typed +0 -0
- pinecall/retrieval/__init__.py +0 -0
- pinecall/retrieval/_search.py +186 -0
- pinecall/retrieval/_sections.py +70 -0
- pinecall/retrieval/embed.py +340 -0
- pinecall/retrieval/extraction.py +403 -0
- pinecall/retrieval/knowledge.py +659 -0
- pinecall/retrieval/lookups.py +192 -0
- pinecall/retrieval/memory.py +603 -0
- pinecall/runner/__init__.py +0 -0
- pinecall/runner/_kube.py +459 -0
- pinecall/runner/_plan.py +157 -0
- pinecall/runner/main.py +306 -0
- pinecall/session/__init__.py +0 -0
- pinecall/session/_agent.py +141 -0
- pinecall/session/_hearing.py +101 -0
- pinecall/session/_livekit.py +195 -0
- pinecall/session/_prompt.py +147 -0
- pinecall/session/_recording.py +237 -0
- pinecall/session/call.py +367 -0
- pinecall/session/clock.py +40 -0
- pinecall/session/hold.py +239 -0
- pinecall/session/room.py +626 -0
- pinecall/session/session.py +690 -0
- pinecall/session/text.py +144 -0
- pinecall/session/tools.py +483 -0
- pinecall/session/voice.py +118 -0
- pinecall/session/widget.py +201 -0
- pinecall/tenancy/__init__.py +0 -0
- pinecall/tenancy/admission.py +204 -0
- pinecall/tenancy/agents.py +164 -0
- pinecall/tenancy/canary.py +126 -0
- pinecall/tenancy/carrier_networks.py +156 -0
- pinecall/tenancy/carriers.py +265 -0
- pinecall/tenancy/codes.py +196 -0
- pinecall/tenancy/consents.py +167 -0
- pinecall/tenancy/dial_policy.py +405 -0
- pinecall/tenancy/disclosure.py +41 -0
- pinecall/tenancy/erasure.py +204 -0
- pinecall/tenancy/export.py +167 -0
- pinecall/tenancy/hosted_running.py +166 -0
- pinecall/tenancy/hosting.py +475 -0
- pinecall/tenancy/judges.py +126 -0
- pinecall/tenancy/keys.py +467 -0
- pinecall/tenancy/knocks.py +40 -0
- pinecall/tenancy/letters.py +378 -0
- pinecall/tenancy/mail.py +458 -0
- pinecall/tenancy/org_secrets.py +123 -0
- pinecall/tenancy/orgs.py +78 -0
- pinecall/tenancy/people.py +543 -0
- pinecall/tenancy/personas.py +145 -0
- pinecall/tenancy/policy.py +65 -0
- pinecall/tenancy/prompts.py +48 -0
- pinecall/tenancy/reads.py +73 -0
- pinecall/tenancy/recording_keys.py +41 -0
- pinecall/tenancy/remembered.py +148 -0
- pinecall/tenancy/retention.py +86 -0
- pinecall/tenancy/scopes.py +406 -0
- pinecall/tenancy/signin.py +622 -0
- pinecall/tenancy/spend.py +69 -0
- pinecall/tenancy/sso.py +431 -0
- pinecall/tenancy/sso_domains.py +132 -0
- pinecall/tenancy/throttle.py +88 -0
- pinecall/tenancy/tokens.py +400 -0
- pinecall/tenancy/traceback.py +70 -0
- pinecall/tenancy/usage.py +136 -0
- pinecall/tenancy/vault.py +204 -0
- pinecall/tenancy/words.py +144 -0
- pinecall/wire/__init__.py +0 -0
- pinecall/wire/commands.py +350 -0
- pinecall/wire/events.py +655 -0
- pinecall/wire/frames.py +65 -0
- pinecall/wire/metrics.py +287 -0
- pinecall/wire/parts.py +320 -0
- pinecall/wire/rest/__init__.py +0 -0
- pinecall/wire/rest/accounts.py +512 -0
- pinecall/wire/rest/agents.py +172 -0
- pinecall/wire/rest/calls.py +397 -0
- pinecall/wire/rest/evals.py +453 -0
- pinecall/wire/rest/fleet.py +69 -0
- pinecall/wire/rest/hosting.py +174 -0
- pinecall/wire/rest/numbers.py +280 -0
- pinecall/wire/rest/ops.py +342 -0
- pinecall/wire/rest/providers.py +83 -0
- pinecall/wire/rest/retrieval.py +301 -0
- pinecall/wire/rest/settings.py +184 -0
- pinecall/wire/rest/usage.py +245 -0
- pinecall/wire/room.py +61 -0
- pinecall/wire/scores.py +49 -0
- pinecall/wire/state.py +243 -0
- pinecall/worker/__init__.py +0 -0
- pinecall/worker/_job.py +655 -0
- pinecall/worker/_recorder.py +82 -0
- pinecall/worker/_traces.py +48 -0
- pinecall/worker/main.py +307 -0
- pinecall_runtime-0.1.6.dist-info/METADATA +212 -0
- pinecall_runtime-0.1.6.dist-info/RECORD +328 -0
- pinecall_runtime-0.1.6.dist-info/WHEEL +4 -0
- pinecall_runtime-0.1.6.dist-info/entry_points.txt +2 -0
- 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)
|