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,204 @@
|
|
|
1
|
+
"""The vault: the Fernet keys every secret is sealed under, and the vendors' credentials."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from cryptography.fernet import Fernet, InvalidToken, MultiFernet
|
|
9
|
+
from psycopg import sql
|
|
10
|
+
from psycopg.types.json import Jsonb
|
|
11
|
+
|
|
12
|
+
from pinecall.domain.names import Credentials, Json
|
|
13
|
+
from pinecall.postgres.pool import Pool
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
# The box's own credentials sit beside its other settings, one row per vendor.
|
|
18
|
+
BOX_CREDENTIALS = "credentials/"
|
|
19
|
+
|
|
20
|
+
OWN = "SELECT vendor, ciphertext FROM provider_keys WHERE org = %(org)s ORDER BY vendor"
|
|
21
|
+
PUT_OWN = """
|
|
22
|
+
INSERT INTO provider_keys (org, vendor, ciphertext) VALUES (%(org)s, %(vendor)s, %(ciphertext)s)
|
|
23
|
+
ON CONFLICT (org, vendor) DO UPDATE SET ciphertext = excluded.ciphertext, set_at = now()
|
|
24
|
+
"""
|
|
25
|
+
DROP_OWN = "DELETE FROM provider_keys WHERE org = %(org)s AND vendor = %(vendor)s RETURNING vendor"
|
|
26
|
+
BOX = """
|
|
27
|
+
SELECT substr(name, length(%(prefix)s) + 1) AS vendor, ciphertext FROM box_settings
|
|
28
|
+
WHERE starts_with(name, %(prefix)s) AND ciphertext IS NOT NULL ORDER BY name
|
|
29
|
+
"""
|
|
30
|
+
PUT_BOX = """
|
|
31
|
+
INSERT INTO box_settings (name, value, ciphertext) VALUES (%(name)s, %(value)s, %(ciphertext)s)
|
|
32
|
+
ON CONFLICT (name) DO UPDATE SET ciphertext = excluded.ciphertext, set_at = now()
|
|
33
|
+
"""
|
|
34
|
+
DROP_BOX = "DELETE FROM box_settings WHERE name = %(name)s RETURNING name"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# A row's ctid names it for one statement, and the value it still holds guards the write: a row
|
|
38
|
+
# the gateway wrote meanwhile was written under the first key already, and is left as it is.
|
|
39
|
+
TOKENS = "SELECT ctid::text AS row, {column} AS token FROM {table} WHERE {column} IS NOT NULL"
|
|
40
|
+
RESEAL = "UPDATE {table} SET {column} = %(new)s WHERE ctid = %(row)s::tid AND {column} = %(old)s"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class SealedColumn:
|
|
45
|
+
"""A column a secret is kept sealed in, by its table."""
|
|
46
|
+
|
|
47
|
+
table: str
|
|
48
|
+
column: str
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def named(self) -> str:
|
|
52
|
+
"""The column as a person reads it: table.column."""
|
|
53
|
+
return f"{self.table}.{self.column}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Resealing:
|
|
58
|
+
"""One column after a pass: re-sealed now, already under the first key, opened by no key."""
|
|
59
|
+
|
|
60
|
+
column: SealedColumn
|
|
61
|
+
resealed: int = 0
|
|
62
|
+
current: int = 0
|
|
63
|
+
unopened: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Every column of the schema a secret is sealed in. A test walks the schema and every Fernet token
|
|
67
|
+
# in it, so a column added without its line here fails the suite.
|
|
68
|
+
SEALED_COLUMNS: tuple[SealedColumn, ...] = (
|
|
69
|
+
SealedColumn("box_settings", "ciphertext"),
|
|
70
|
+
SealedColumn("call_private", "sealed"),
|
|
71
|
+
SealedColumn("carriers", "ciphertext"),
|
|
72
|
+
SealedColumn("hosted_apps", "sealed_key"),
|
|
73
|
+
SealedColumn("one_use_words", "sealed"),
|
|
74
|
+
SealedColumn("org_mail", "ciphertext"),
|
|
75
|
+
SealedColumn("org_secrets", "sealed"),
|
|
76
|
+
SealedColumn("org_sso", "ciphertext"),
|
|
77
|
+
SealedColumn("provider_keys", "ciphertext"),
|
|
78
|
+
SealedColumn("recording_keys", "sealed"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sealed(vault: MultiFernet, secret: Json) -> str:
|
|
83
|
+
"""The secret as one Fernet token over its JSON."""
|
|
84
|
+
return vault.encrypt(json.dumps(secret).encode()).decode()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# A token sealed under a key the list no longer holds reads as nothing, said in the log: one
|
|
88
|
+
# stale row must not stop every call of the org.
|
|
89
|
+
def opened(vault: MultiFernet, token: str) -> Json:
|
|
90
|
+
"""The secret a token seals; None when no key listed opens it."""
|
|
91
|
+
try:
|
|
92
|
+
return json.loads(vault.decrypt(token.encode()))
|
|
93
|
+
except InvalidToken:
|
|
94
|
+
logger.warning(
|
|
95
|
+
"a secret sealed under a key PINECALL_VAULT_KEY no longer lists reads as unset"
|
|
96
|
+
)
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def credentials_of(pool: Pool, vault: MultiFernet, org: str) -> dict[str, Credentials]:
|
|
101
|
+
"""The vendors' credentials the org brought, opened."""
|
|
102
|
+
async with pool.connection() as connection:
|
|
103
|
+
rows = await (await connection.execute(OWN, {"org": org})).fetchall()
|
|
104
|
+
return _opened_rows(vault, [(row["vendor"], row["ciphertext"]) for row in rows])
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def vendors_of(pool: Pool, org: str) -> list[str]:
|
|
108
|
+
"""The vendors the org brought its own credentials for, never the credentials."""
|
|
109
|
+
async with pool.connection() as connection:
|
|
110
|
+
rows = await (await connection.execute(OWN, {"org": org})).fetchall()
|
|
111
|
+
return [row["vendor"] for row in rows]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def put_credentials(
|
|
115
|
+
pool: Pool, vault: MultiFernet, org: str, vendor: str, credentials: Credentials
|
|
116
|
+
) -> None:
|
|
117
|
+
"""Keep the org's credentials for the vendor, replacing any it had."""
|
|
118
|
+
ciphertext = sealed(vault, credentials)
|
|
119
|
+
async with pool.connection() as connection:
|
|
120
|
+
await connection.execute(PUT_OWN, {"org": org, "vendor": vendor, "ciphertext": ciphertext})
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def drop_credentials(pool: Pool, org: str, vendor: str) -> bool:
|
|
124
|
+
"""Forget the org's credentials for the vendor; whether it had any."""
|
|
125
|
+
async with pool.connection() as connection:
|
|
126
|
+
dropped = await connection.execute(DROP_OWN, {"org": org, "vendor": vendor})
|
|
127
|
+
return await dropped.fetchone() is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
async def box_credentials(pool: Pool, vault: MultiFernet) -> dict[str, Credentials]:
|
|
131
|
+
"""The credentials the box holds for the vendors it offers, opened."""
|
|
132
|
+
async with pool.connection() as connection:
|
|
133
|
+
rows = await (await connection.execute(BOX, {"prefix": BOX_CREDENTIALS})).fetchall()
|
|
134
|
+
return _opened_rows(vault, [(row["vendor"], row["ciphertext"]) for row in rows])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def put_box_credentials(
|
|
138
|
+
pool: Pool, vault: MultiFernet, vendor: str, credentials: Credentials
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Keep the box's credentials for the vendor: offering it is holding its key."""
|
|
141
|
+
row = {
|
|
142
|
+
"name": f"{BOX_CREDENTIALS}{vendor}",
|
|
143
|
+
"value": Jsonb({}),
|
|
144
|
+
"ciphertext": sealed(vault, credentials),
|
|
145
|
+
}
|
|
146
|
+
async with pool.connection() as connection:
|
|
147
|
+
await connection.execute(PUT_BOX, row)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def drop_box_credentials(pool: Pool, vendor: str) -> bool:
|
|
151
|
+
"""Stop offering the vendor on the box's key; whether the box held one."""
|
|
152
|
+
async with pool.connection() as connection:
|
|
153
|
+
dropped = await connection.execute(DROP_BOX, {"name": f"{BOX_CREDENTIALS}{vendor}"})
|
|
154
|
+
return await dropped.fetchone() is not None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Row by row, each its own statement: a pass cut short keeps what it did, and the next pass
|
|
158
|
+
# finds those rows under the first key and leaves them. The first key must be first everywhere
|
|
159
|
+
# (the gateway restarted on the new list) before the old one is taken out.
|
|
160
|
+
async def resealed(pool: Pool, keyring: Sequence[Fernet]) -> list[Resealing]:
|
|
161
|
+
"""Seal every sealed value of the schema under the keyring's first key, and count each."""
|
|
162
|
+
return [await _resealed_column(pool, keyring, column) for column in SEALED_COLUMNS]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _resealed_column(
|
|
166
|
+
pool: Pool, keyring: Sequence[Fernet], column: SealedColumn
|
|
167
|
+
) -> Resealing:
|
|
168
|
+
names = {"table": sql.Identifier(column.table), "column": sql.Identifier(column.column)}
|
|
169
|
+
ring, first = MultiFernet(keyring), keyring[0]
|
|
170
|
+
async with pool.connection() as connection:
|
|
171
|
+
rows = await (await connection.execute(sql.SQL(TOKENS).format(**names))).fetchall()
|
|
172
|
+
resealed = current = unopened = 0
|
|
173
|
+
for row in rows:
|
|
174
|
+
token = str(row["token"]).encode()
|
|
175
|
+
if _opens(first, token):
|
|
176
|
+
current += 1
|
|
177
|
+
continue
|
|
178
|
+
try:
|
|
179
|
+
new = ring.rotate(token).decode()
|
|
180
|
+
except InvalidToken:
|
|
181
|
+
unopened += 1
|
|
182
|
+
continue
|
|
183
|
+
values = {"new": new, "row": row["row"], "old": row["token"]}
|
|
184
|
+
async with pool.connection() as connection:
|
|
185
|
+
written = await connection.execute(sql.SQL(RESEAL).format(**names), values)
|
|
186
|
+
resealed += written.rowcount
|
|
187
|
+
return Resealing(column, resealed=resealed, current=current, unopened=unopened)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _opens(key: Fernet, token: bytes) -> bool:
|
|
191
|
+
try:
|
|
192
|
+
key.decrypt(token)
|
|
193
|
+
except InvalidToken:
|
|
194
|
+
return False
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _opened_rows(vault: MultiFernet, rows: list[tuple[str, str]]) -> dict[str, Credentials]:
|
|
199
|
+
kept: dict[str, Credentials] = {}
|
|
200
|
+
for vendor, ciphertext in rows:
|
|
201
|
+
secret = opened(vault, ciphertext)
|
|
202
|
+
if isinstance(secret, str | dict):
|
|
203
|
+
kept[vendor] = secret
|
|
204
|
+
return kept
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Words spent once, kept in Postgres: any gateway mints one, any gateway spends it."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import secrets
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from cryptography.fernet import MultiFernet
|
|
10
|
+
from pydantic import TypeAdapter
|
|
11
|
+
|
|
12
|
+
from pinecall.postgres.pool import Pool
|
|
13
|
+
|
|
14
|
+
WORD_BYTES = 24
|
|
15
|
+
|
|
16
|
+
# The word is kept as its hash and the value sealed: a copy of the table opens nothing.
|
|
17
|
+
KEPT = """
|
|
18
|
+
insert into one_use_words (word_hash, kind, sealed, expires_at)
|
|
19
|
+
values (%(hash)s, %(kind)s, %(sealed)s, %(expires_at)s)
|
|
20
|
+
on conflict (word_hash) do update
|
|
21
|
+
set kind = excluded.kind, sealed = excluded.sealed, expires_at = excluded.expires_at, attempts = 0
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
READ = "select sealed, expires_at, attempts from one_use_words where word_hash = %(hash)s"
|
|
25
|
+
|
|
26
|
+
# Spent once: of two gateways spending one word, one gets the value.
|
|
27
|
+
SPENT = """
|
|
28
|
+
delete from one_use_words where word_hash = %(hash)s and expires_at > %(now)s
|
|
29
|
+
returning sealed, expires_at
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# The value changed in place, its end kept: a terminal given its key.
|
|
33
|
+
FILLED = """
|
|
34
|
+
update one_use_words set sealed = %(sealed)s
|
|
35
|
+
where word_hash = %(hash)s and expires_at > %(now)s
|
|
36
|
+
returning word_hash
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
TRIED = """
|
|
40
|
+
update one_use_words set attempts = attempts + 1 where word_hash = %(hash)s returning attempts
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
FORGOTTEN = "delete from one_use_words where word_hash = %(hash)s"
|
|
44
|
+
|
|
45
|
+
SWEPT = "delete from one_use_words where expires_at <= %(now)s"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Words:
|
|
50
|
+
"""Where words are kept, the vault their values are sealed under, and the clock they die by."""
|
|
51
|
+
|
|
52
|
+
pool: Pool
|
|
53
|
+
vault: MultiFernet
|
|
54
|
+
clock: Callable[[], float] = time.time
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class OneUseValue[T]:
|
|
59
|
+
"""A value kept under a one-use word, until when, and how often a guess at it failed."""
|
|
60
|
+
|
|
61
|
+
value: T
|
|
62
|
+
expires_at: float
|
|
63
|
+
attempts: int = 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class OneUse[T]:
|
|
67
|
+
"""Words of one kind spent once, each dying on its own."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, kind: tuple[str, float], adapter: TypeAdapter[T], words: Words) -> None:
|
|
70
|
+
"""The kind is the words' prefix and how long they live; the adapter seals their values."""
|
|
71
|
+
self.prefix, self.ttl_s = kind
|
|
72
|
+
self.adapter = adapter
|
|
73
|
+
self.words = words
|
|
74
|
+
|
|
75
|
+
def word(self) -> str:
|
|
76
|
+
"""A new word, not yet holding anything."""
|
|
77
|
+
return f"{self.prefix}{secrets.token_urlsafe(WORD_BYTES)}"
|
|
78
|
+
|
|
79
|
+
async def keep(self, word: str, value: T) -> float:
|
|
80
|
+
"""Hold the value under the word until it dies, and say when."""
|
|
81
|
+
now = self.words.clock()
|
|
82
|
+
expires_at = now + self.ttl_s
|
|
83
|
+
row = {
|
|
84
|
+
"hash": _hashed(word),
|
|
85
|
+
"kind": self.prefix,
|
|
86
|
+
"sealed": self._sealed(value),
|
|
87
|
+
"expires_at": expires_at,
|
|
88
|
+
}
|
|
89
|
+
async with self.words.pool.connection() as connection, connection.transaction():
|
|
90
|
+
await connection.execute(SWEPT, {"now": now})
|
|
91
|
+
await connection.execute(KEPT, row)
|
|
92
|
+
return expires_at
|
|
93
|
+
|
|
94
|
+
async def mint(self, value: T) -> tuple[str, float]:
|
|
95
|
+
"""A new word holding the value, and when it dies."""
|
|
96
|
+
word = self.word()
|
|
97
|
+
return word, await self.keep(word, value)
|
|
98
|
+
|
|
99
|
+
async def read(self, word: str) -> OneUseValue[T] | None:
|
|
100
|
+
"""What the word holds, dead or not, without spending it."""
|
|
101
|
+
async with self.words.pool.connection() as connection:
|
|
102
|
+
row = await (await connection.execute(READ, {"hash": _hashed(word)})).fetchone()
|
|
103
|
+
if row is None:
|
|
104
|
+
return None
|
|
105
|
+
return OneUseValue(self._opened(row["sealed"]), row["expires_at"], row["attempts"])
|
|
106
|
+
|
|
107
|
+
async def alive(self, word: str) -> OneUseValue[T] | None:
|
|
108
|
+
"""What the word holds while it lives, without spending it."""
|
|
109
|
+
found = await self.read(word)
|
|
110
|
+
return None if found is None or found.expires_at <= self.words.clock() else found
|
|
111
|
+
|
|
112
|
+
async def spend(self, word: str) -> T | None:
|
|
113
|
+
"""What the word holds, spent: it opens nothing after."""
|
|
114
|
+
wanted = {"hash": _hashed(word), "now": self.words.clock()}
|
|
115
|
+
async with self.words.pool.connection() as connection:
|
|
116
|
+
row = await (await connection.execute(SPENT, wanted)).fetchone()
|
|
117
|
+
return None if row is None else self._opened(row["sealed"])
|
|
118
|
+
|
|
119
|
+
async def fill(self, word: str, value: T) -> bool:
|
|
120
|
+
"""Change what a living word holds, its end kept; False for a dead one."""
|
|
121
|
+
wanted = {"hash": _hashed(word), "now": self.words.clock(), "sealed": self._sealed(value)}
|
|
122
|
+
async with self.words.pool.connection() as connection:
|
|
123
|
+
return await (await connection.execute(FILLED, wanted)).fetchone() is not None
|
|
124
|
+
|
|
125
|
+
async def tried(self, word: str) -> int:
|
|
126
|
+
"""Count one more wrong guess at the word, and say how many there were."""
|
|
127
|
+
async with self.words.pool.connection() as connection:
|
|
128
|
+
row = await (await connection.execute(TRIED, {"hash": _hashed(word)})).fetchone()
|
|
129
|
+
return 0 if row is None else int(row["attempts"])
|
|
130
|
+
|
|
131
|
+
async def forget(self, word: str) -> None:
|
|
132
|
+
"""The word opens nothing from now on."""
|
|
133
|
+
async with self.words.pool.connection() as connection:
|
|
134
|
+
await connection.execute(FORGOTTEN, {"hash": _hashed(word)})
|
|
135
|
+
|
|
136
|
+
def _sealed(self, value: T) -> str:
|
|
137
|
+
return self.words.vault.encrypt(self.adapter.dump_json(value)).decode()
|
|
138
|
+
|
|
139
|
+
def _opened(self, sealed: str) -> T:
|
|
140
|
+
return self.adapter.validate_json(self.words.vault.decrypt(sealed.encode()))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _hashed(word: str) -> bytes:
|
|
144
|
+
return hashlib.sha256(word.encode()).digest()
|
|
File without changes
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Every command an app sends, the supervise verbs, and the registry of them."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from pinecall.domain.errors import DeclarationRefused
|
|
8
|
+
from pinecall.domain.names import JsonObject
|
|
9
|
+
from pinecall.wire.frames import Command, WireModel
|
|
10
|
+
from pinecall.wire.parts import (
|
|
11
|
+
AgentConfig,
|
|
12
|
+
Contact,
|
|
13
|
+
Route,
|
|
14
|
+
Supervisor,
|
|
15
|
+
ToolResult,
|
|
16
|
+
ToolSpec,
|
|
17
|
+
TransferMode,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# Which events a command lands in the log as, so a caller knows what to wait for.
|
|
21
|
+
PRODUCES: dict[str, tuple[str, ...]] = {
|
|
22
|
+
"agent.configure": ("agent.configured",),
|
|
23
|
+
"agent.drain": ("agent.draining",),
|
|
24
|
+
"agent.register": ("agent.registered",),
|
|
25
|
+
"agent.reply": ("turn.agent",),
|
|
26
|
+
"agent.say": ("turn.agent",),
|
|
27
|
+
"call.attention": ("attention.requested", "call.line", "attention.answered"),
|
|
28
|
+
"call.callback": ("callback.requested",),
|
|
29
|
+
"call.claim": ("call.claimed",),
|
|
30
|
+
"call.dial": ("call.dialing",),
|
|
31
|
+
"call.dtmf": (),
|
|
32
|
+
"call.opt_out": (),
|
|
33
|
+
"call.event": ("event.received",),
|
|
34
|
+
"call.hangup": ("call.ended",),
|
|
35
|
+
"call.hold": ("call.line",),
|
|
36
|
+
"call.log": ("custom",),
|
|
37
|
+
"call.mute": ("call.line",),
|
|
38
|
+
"call.transfer": ("call.transferred",),
|
|
39
|
+
"call.unhold": ("call.line",),
|
|
40
|
+
"call.unmute": ("call.line",),
|
|
41
|
+
"dev.answer": (),
|
|
42
|
+
"participant.mute": ("track.unpublished",),
|
|
43
|
+
"participant.remove": ("participant.left",),
|
|
44
|
+
"ping": ("pong",),
|
|
45
|
+
"prompt.set": ("prompt.changed",),
|
|
46
|
+
"room.invite": ("participant.joined",),
|
|
47
|
+
"room.send": ("room.sent",),
|
|
48
|
+
"session.configure": ("state.changed", "agent.configured"),
|
|
49
|
+
"state.set": ("state.changed",),
|
|
50
|
+
"supervisor.verb": (
|
|
51
|
+
"supervisor.said",
|
|
52
|
+
"supervisor.whispered",
|
|
53
|
+
"supervisor.took_over",
|
|
54
|
+
"supervisor.released",
|
|
55
|
+
"supervisor.transferred",
|
|
56
|
+
"supervisor.ended",
|
|
57
|
+
),
|
|
58
|
+
"tool.result": ("tool.result",),
|
|
59
|
+
"tools.set": ("tools.changed",),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class SayVerb(WireModel):
|
|
64
|
+
"""Make the agent say this, verbatim, to the caller."""
|
|
65
|
+
|
|
66
|
+
verb: Literal["say"] = "say"
|
|
67
|
+
text: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WhisperVerb(WireModel):
|
|
71
|
+
"""Tell the agent something the caller never hears."""
|
|
72
|
+
|
|
73
|
+
verb: Literal["whisper"] = "whisper"
|
|
74
|
+
text: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TakeoverVerb(WireModel):
|
|
78
|
+
"""The supervisor takes the line."""
|
|
79
|
+
|
|
80
|
+
verb: Literal["takeover"] = "takeover"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ReleaseVerb(WireModel):
|
|
84
|
+
"""The supervisor hands the line back to the agent."""
|
|
85
|
+
|
|
86
|
+
verb: Literal["release"] = "release"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TransferVerb(WireModel):
|
|
90
|
+
"""Send the caller to another number."""
|
|
91
|
+
|
|
92
|
+
verb: Literal["transfer"] = "transfer"
|
|
93
|
+
to: str
|
|
94
|
+
mode: TransferMode | None = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class EndVerb(WireModel):
|
|
98
|
+
"""Hang up on the caller's behalf, at once."""
|
|
99
|
+
|
|
100
|
+
verb: Literal["end"] = "end"
|
|
101
|
+
reason: str | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
type Verb = Annotated[
|
|
105
|
+
SayVerb | WhisperVerb | TakeoverVerb | ReleaseVerb | TransferVerb | EndVerb,
|
|
106
|
+
Field(discriminator="verb"),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AgentConfigure(WireModel):
|
|
111
|
+
"""Declare or change what the agent is; only the fields sent change."""
|
|
112
|
+
|
|
113
|
+
config: AgentConfig
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class AgentDrain(WireModel):
|
|
117
|
+
"""This socket is leaving, and its calls go on without it."""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class AgentRegister(WireModel):
|
|
121
|
+
"""The app's first message: this socket speaks for this agent and answers these doors."""
|
|
122
|
+
|
|
123
|
+
routes: list[Route]
|
|
124
|
+
sdk: str | None = None
|
|
125
|
+
host: str | None = None
|
|
126
|
+
takes_unclaimed: bool = True
|
|
127
|
+
answers_dev: bool = False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class AgentReply(WireModel):
|
|
131
|
+
"""Make the model speak now, guided by an instruction it reads and the caller never hears."""
|
|
132
|
+
|
|
133
|
+
instructions: str
|
|
134
|
+
allow_interruptions: bool | None = None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AgentSay(WireModel):
|
|
138
|
+
"""Make the agent say this text now, verbatim, outside the model's turn."""
|
|
139
|
+
|
|
140
|
+
text: str
|
|
141
|
+
allow_interruptions: bool | None = None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class CallAttention(WireModel):
|
|
145
|
+
"""Ask for a person without sending the caller anywhere."""
|
|
146
|
+
|
|
147
|
+
reason: str
|
|
148
|
+
# Fifteen minutes at most: past it nobody is coming, and the call holds a worker's seat.
|
|
149
|
+
wait_s: float = Field(gt=0, le=900)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class CallCallback(WireModel):
|
|
153
|
+
"""Write down that the caller wants to be called back."""
|
|
154
|
+
|
|
155
|
+
number: str
|
|
156
|
+
when: str | None = None
|
|
157
|
+
note: str | None = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class CallClaim(WireModel):
|
|
161
|
+
"""The code the caller said, to bind this call to the page that shows it."""
|
|
162
|
+
|
|
163
|
+
code: str = Field(pattern="^[0-9]{4}$")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class CallDial(WireModel):
|
|
167
|
+
"""Place an outbound call as this agent."""
|
|
168
|
+
|
|
169
|
+
to: str
|
|
170
|
+
from_: str | None = Field(None, alias="from")
|
|
171
|
+
caller: Contact | None = None
|
|
172
|
+
metadata: JsonObject | None = None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class CallOptOut(WireModel):
|
|
176
|
+
"""The caller asked never to be called again: their number joins the org's do-not-call list."""
|
|
177
|
+
|
|
178
|
+
note: str | None = Field(default=None, max_length=500)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class CallDtmf(WireModel):
|
|
182
|
+
"""Send touch tones down the line, for an IVR on the far end."""
|
|
183
|
+
|
|
184
|
+
digits: str
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class CallEvent(WireModel):
|
|
188
|
+
"""Hand the agent a fact from the tenant's backend; lands as event.received."""
|
|
189
|
+
|
|
190
|
+
name: str
|
|
191
|
+
data: JsonObject
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class CallHangup(WireModel):
|
|
195
|
+
"""End the call from the app's side."""
|
|
196
|
+
|
|
197
|
+
reason: str | None = None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class CallHold(WireModel):
|
|
201
|
+
"""Put the caller on hold: they hear hold audio, the agent hears nothing."""
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class CallLog(WireModel):
|
|
205
|
+
"""Write a line of the app's own into the call's log."""
|
|
206
|
+
|
|
207
|
+
name: str
|
|
208
|
+
data: JsonObject
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class CallMute(WireModel):
|
|
212
|
+
"""Mute the agent: it keeps listening and thinking, produces no audio."""
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class CallTransfer(WireModel):
|
|
216
|
+
"""Send the caller to another number, or bring that number into the call."""
|
|
217
|
+
|
|
218
|
+
to: str
|
|
219
|
+
mode: TransferMode | None = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class CallUnhold(WireModel):
|
|
223
|
+
"""Take the caller off hold."""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class CallUnmute(WireModel):
|
|
227
|
+
"""Unmute the agent."""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class DevRefusal(WireModel):
|
|
231
|
+
"""Why the verb did not run, in the words the console shows."""
|
|
232
|
+
|
|
233
|
+
status: int
|
|
234
|
+
detail: str
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class DevAnswer(WireModel):
|
|
238
|
+
"""What came of one dev.request, named by its id."""
|
|
239
|
+
|
|
240
|
+
id: str
|
|
241
|
+
result: JsonObject | None = None
|
|
242
|
+
refused: DevRefusal | None = None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ParticipantMute(WireModel):
|
|
246
|
+
"""Silence a participant for the rest of the call."""
|
|
247
|
+
|
|
248
|
+
identity: str
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class ParticipantRemove(WireModel):
|
|
252
|
+
"""Put a participant out of the room; removing the caller ends the call."""
|
|
253
|
+
|
|
254
|
+
identity: str
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Ping(WireModel):
|
|
258
|
+
"""Is the socket alive? The gateway answers pong."""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class PromptSet(WireModel):
|
|
262
|
+
"""Rewrite one block of the prompt, whole, by name."""
|
|
263
|
+
|
|
264
|
+
name: str
|
|
265
|
+
text: str
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class RoomInvite(WireModel):
|
|
269
|
+
"""Bring somebody else into the call's room."""
|
|
270
|
+
|
|
271
|
+
to: str
|
|
272
|
+
kind: Literal["sip", "participant"]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class RoomSend(WireModel):
|
|
276
|
+
"""Push a payload to a browser in the room over the DataChannel."""
|
|
277
|
+
|
|
278
|
+
topic: str
|
|
279
|
+
data: JsonObject
|
|
280
|
+
to: str | None = None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class SessionConfigure(WireModel):
|
|
284
|
+
"""Set up this one call before the first turn."""
|
|
285
|
+
|
|
286
|
+
state: JsonObject | None = None
|
|
287
|
+
config: AgentConfig | None = None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
class StateSet(WireModel):
|
|
291
|
+
"""The app's state changed and this is all of it."""
|
|
292
|
+
|
|
293
|
+
state: JsonObject
|
|
294
|
+
changed: list[str] | None = None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class SupervisorVerb(WireModel):
|
|
298
|
+
"""One supervise verb, from the human the door named."""
|
|
299
|
+
|
|
300
|
+
by: Supervisor
|
|
301
|
+
verb: Verb
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class ToolsSet(WireModel):
|
|
305
|
+
"""The tools the model may see now."""
|
|
306
|
+
|
|
307
|
+
tools: list[ToolSpec]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
COMMANDS: dict[str, type[WireModel]] = {
|
|
311
|
+
"agent.configure": AgentConfigure,
|
|
312
|
+
"agent.drain": AgentDrain,
|
|
313
|
+
"agent.register": AgentRegister,
|
|
314
|
+
"agent.reply": AgentReply,
|
|
315
|
+
"agent.say": AgentSay,
|
|
316
|
+
"call.attention": CallAttention,
|
|
317
|
+
"call.callback": CallCallback,
|
|
318
|
+
"call.claim": CallClaim,
|
|
319
|
+
"call.dial": CallDial,
|
|
320
|
+
"call.dtmf": CallDtmf,
|
|
321
|
+
"call.opt_out": CallOptOut,
|
|
322
|
+
"call.event": CallEvent,
|
|
323
|
+
"call.hangup": CallHangup,
|
|
324
|
+
"call.hold": CallHold,
|
|
325
|
+
"call.log": CallLog,
|
|
326
|
+
"call.mute": CallMute,
|
|
327
|
+
"call.transfer": CallTransfer,
|
|
328
|
+
"call.unhold": CallUnhold,
|
|
329
|
+
"call.unmute": CallUnmute,
|
|
330
|
+
"dev.answer": DevAnswer,
|
|
331
|
+
"participant.mute": ParticipantMute,
|
|
332
|
+
"participant.remove": ParticipantRemove,
|
|
333
|
+
"ping": Ping,
|
|
334
|
+
"prompt.set": PromptSet,
|
|
335
|
+
"room.invite": RoomInvite,
|
|
336
|
+
"room.send": RoomSend,
|
|
337
|
+
"session.configure": SessionConfigure,
|
|
338
|
+
"state.set": StateSet,
|
|
339
|
+
"supervisor.verb": SupervisorVerb,
|
|
340
|
+
"tool.result": ToolResult,
|
|
341
|
+
"tools.set": ToolsSet,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def command_of(command: Command) -> WireModel:
|
|
346
|
+
"""Return the command's data as the model its type names; an unknown type is refused."""
|
|
347
|
+
model = COMMANDS.get(command.type)
|
|
348
|
+
if model is None:
|
|
349
|
+
raise DeclarationRefused(f"unknown command type: {command.type}")
|
|
350
|
+
return model.read(command.data, command.type)
|