pinecall-protocol 0.1.0__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_protocol/__init__.py +41 -0
- pinecall_protocol/_base.py +13 -0
- pinecall_protocol/_version.py +4 -0
- pinecall_protocol/codec.py +52 -0
- pinecall_protocol/commands.py +208 -0
- pinecall_protocol/defs.py +407 -0
- pinecall_protocol/envelope.py +30 -0
- pinecall_protocol/events.py +471 -0
- pinecall_protocol/fixtures/__init__.py +7 -0
- pinecall_protocol/fixtures/call-log-golden.json +127 -0
- pinecall_protocol/fixtures/call-log-golden.state.json +39 -0
- pinecall_protocol/metrics.py +303 -0
- pinecall_protocol/py.typed +0 -0
- pinecall_protocol/registry.py +341 -0
- pinecall_protocol/rest.py +344 -0
- pinecall_protocol/room.py +83 -0
- pinecall_protocol/state.py +241 -0
- pinecall_protocol/verbs.py +63 -0
- pinecall_protocol-0.1.0.dist-info/METADATA +15 -0
- pinecall_protocol-0.1.0.dist-info/RECORD +22 -0
- pinecall_protocol-0.1.0.dist-info/WHEEL +4 -0
- pinecall_protocol-0.1.0.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Generated from schema/ by `python generate`. Never edited by hand."""
|
|
2
|
+
|
|
3
|
+
from pinecall_protocol._base import ProtocolError, WireModel
|
|
4
|
+
from pinecall_protocol.codec import (
|
|
5
|
+
command_of,
|
|
6
|
+
decode_entries,
|
|
7
|
+
decode_entry,
|
|
8
|
+
encode,
|
|
9
|
+
event_of,
|
|
10
|
+
)
|
|
11
|
+
from pinecall_protocol.envelope import Command, Entry
|
|
12
|
+
from pinecall_protocol.registry import (
|
|
13
|
+
COMMANDS,
|
|
14
|
+
EPHEMERAL_EVENTS,
|
|
15
|
+
EVENTS,
|
|
16
|
+
PRODUCES,
|
|
17
|
+
TERMINAL_EVENT,
|
|
18
|
+
CommandType,
|
|
19
|
+
EventType,
|
|
20
|
+
)
|
|
21
|
+
from pinecall_protocol.state import State
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"COMMANDS",
|
|
25
|
+
"EPHEMERAL_EVENTS",
|
|
26
|
+
"EVENTS",
|
|
27
|
+
"PRODUCES",
|
|
28
|
+
"TERMINAL_EVENT",
|
|
29
|
+
"Command",
|
|
30
|
+
"CommandType",
|
|
31
|
+
"Entry",
|
|
32
|
+
"EventType",
|
|
33
|
+
"ProtocolError",
|
|
34
|
+
"State",
|
|
35
|
+
"WireModel",
|
|
36
|
+
"command_of",
|
|
37
|
+
"decode_entries",
|
|
38
|
+
"decode_entry",
|
|
39
|
+
"encode",
|
|
40
|
+
"event_of",
|
|
41
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""The base every wire model shares: unknown keys are refused; a field builds by name or alias."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class WireModel(BaseModel):
|
|
7
|
+
"""A shape on the wire. extra=forbid is the schema's additionalProperties: false."""
|
|
8
|
+
|
|
9
|
+
model_config = ConfigDict(extra="forbid", validate_by_name=True, validate_by_alias=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ProtocolError(ValueError):
|
|
13
|
+
"""A message did not match the protocol: unknown type, bad shape, a seq out of order."""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""JSON in, models out, and back. The only file that touches a key name: aliases, by_alias."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import TypeAdapter, ValidationError
|
|
6
|
+
|
|
7
|
+
from pinecall_protocol._base import ProtocolError, WireModel
|
|
8
|
+
from pinecall_protocol.envelope import Command, Entry
|
|
9
|
+
from pinecall_protocol.registry import COMMANDS, EVENTS
|
|
10
|
+
|
|
11
|
+
_LOG: TypeAdapter[list[Entry]] = TypeAdapter(list[Entry])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def decode_entry(raw: dict[str, Any]) -> Entry:
|
|
15
|
+
"""One log line from decoded JSON. A bad shape is a ProtocolError."""
|
|
16
|
+
return _validate(Entry, raw, "entry")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def decode_entries(text: str) -> list[Entry]:
|
|
20
|
+
"""A whole log from a JSON array text, in the order it came."""
|
|
21
|
+
try:
|
|
22
|
+
return _LOG.validate_json(text)
|
|
23
|
+
except ValidationError as error:
|
|
24
|
+
raise ProtocolError(f"log: {error}") from error
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def event_of(entry: Entry) -> WireModel:
|
|
28
|
+
"""The entry's data as the model its type names. Unknown type or bad shape: ProtocolError."""
|
|
29
|
+
model = EVENTS.get(entry.type)
|
|
30
|
+
if model is None:
|
|
31
|
+
raise ProtocolError(f"unknown event type: {entry.type}")
|
|
32
|
+
return _validate(model, entry.data, entry.type)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def command_of(command: Command) -> WireModel:
|
|
36
|
+
"""The command's data as the model its type names. Unknown type or bad shape: ProtocolError."""
|
|
37
|
+
model = COMMANDS.get(command.type)
|
|
38
|
+
if model is None:
|
|
39
|
+
raise ProtocolError(f"unknown command type: {command.type}")
|
|
40
|
+
return _validate(model, command.data, command.type)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def encode(model: WireModel) -> dict[str, Any]:
|
|
44
|
+
"""A model as it goes on the wire: wire key names, absent fields absent, JSON-ready values."""
|
|
45
|
+
return model.model_dump(mode="json", by_alias=True, exclude_unset=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _validate[T: WireModel](model: type[T], raw: dict[str, Any], what: str) -> T:
|
|
49
|
+
try:
|
|
50
|
+
return model.model_validate(raw)
|
|
51
|
+
except ValidationError as error:
|
|
52
|
+
raise ProtocolError(f"{what}: {error}") from error
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Generated from schema/commands/: one model per command."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from pinecall_protocol._base import WireModel
|
|
8
|
+
from pinecall_protocol.defs import AgentConfig, Contact, Route, Supervisor, ToolSpec, TransferMode
|
|
9
|
+
from pinecall_protocol.verbs import Verb
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Declare or change what the agent is: voice, models, language, greeting, the full tool list. Only
|
|
13
|
+
# the fields sent change.
|
|
14
|
+
class AgentConfigure(WireModel):
|
|
15
|
+
"""Declare or change what the agent is."""
|
|
16
|
+
|
|
17
|
+
config: AgentConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# The gateway answers agent.registered, or error.
|
|
21
|
+
class AgentRegister(WireModel):
|
|
22
|
+
"""The app's first message: this socket speaks for this agent and answers these doors."""
|
|
23
|
+
|
|
24
|
+
routes: list[Route]
|
|
25
|
+
sdk: str | None = None
|
|
26
|
+
takes_unclaimed: bool = True
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Make the model speak now, guided by an instruction it reads and the caller never hears: 'tell them
|
|
30
|
+
# a slot at 10:15 just opened'. On livekit's session.generate_reply; the sibling of agent.say, which
|
|
31
|
+
# speaks verbatim. The reply lands as turn.agent.
|
|
32
|
+
class AgentReply(WireModel):
|
|
33
|
+
"""Make the model speak now, guided by an instruction it reads and the caller never hears."""
|
|
34
|
+
|
|
35
|
+
instructions: str
|
|
36
|
+
allow_interruptions: bool | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Make the agent say this text now, verbatim, outside the model's turn: a greeting, a read-back, a
|
|
40
|
+
# system notice. The reply lands as turn.agent.
|
|
41
|
+
class AgentSay(WireModel):
|
|
42
|
+
"""Make the agent say this text now, verbatim, outside the model's turn."""
|
|
43
|
+
|
|
44
|
+
text: str
|
|
45
|
+
allow_interruptions: bool | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The new call's log opens with call.dialing; call.started follows when the far end answers.
|
|
49
|
+
class CallDial(WireModel):
|
|
50
|
+
"""Place an outbound call as this agent."""
|
|
51
|
+
|
|
52
|
+
to: str
|
|
53
|
+
from_: str | None = Field(None, alias="from")
|
|
54
|
+
caller: Contact | None = None
|
|
55
|
+
metadata: dict[str, Any] | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class CallDtmf(WireModel):
|
|
59
|
+
"""Send touch tones down the line, for an IVR on the far end."""
|
|
60
|
+
|
|
61
|
+
digits: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Hand the agent a fact from the tenant's backend: a slot freed, an order shipped, a payment
|
|
65
|
+
# confirmed. Lands as event.received with source app. The agent must have declared the name in its
|
|
66
|
+
# events with app among the senders, or the gateway answers error and nothing touches the log.
|
|
67
|
+
class CallEvent(WireModel):
|
|
68
|
+
"""Hand the agent a fact from the tenant's backend."""
|
|
69
|
+
|
|
70
|
+
name: str
|
|
71
|
+
data: dict[str, Any]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# call.ended follows with reason agent_hung_up.
|
|
75
|
+
class CallHangup(WireModel):
|
|
76
|
+
"""End the call from the app's side."""
|
|
77
|
+
|
|
78
|
+
reason: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class CallHold(WireModel):
|
|
82
|
+
"""Put the caller on hold: they hear hold audio, the agent hears nothing."""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# It gets a seq like everything else and lands as custom.
|
|
86
|
+
class CallLog(WireModel):
|
|
87
|
+
"""Write a line of the app's own into the call's log."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
data: dict[str, Any]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class CallMute(WireModel):
|
|
94
|
+
"""Mute the agent: it keeps listening and thinking, produces no audio."""
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# call.transferred says whether it worked.
|
|
98
|
+
class CallTransfer(WireModel):
|
|
99
|
+
"""Send the caller to another number."""
|
|
100
|
+
|
|
101
|
+
to: str
|
|
102
|
+
mode: TransferMode
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class CallUnhold(WireModel):
|
|
106
|
+
"""Take the caller off hold."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CallUnmute(WireModel):
|
|
110
|
+
"""Unmute the agent."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Why the verb did not run, in the words the console shows: the status it travels under, and the
|
|
114
|
+
# sentence.
|
|
115
|
+
class DevRefusal(WireModel):
|
|
116
|
+
"""Why the verb did not run, in the words the console shows."""
|
|
117
|
+
|
|
118
|
+
status: int
|
|
119
|
+
detail: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DevAnswer(WireModel):
|
|
123
|
+
"""What came of one dev.request, named by its id."""
|
|
124
|
+
|
|
125
|
+
id: str
|
|
126
|
+
result: dict[str, Any] | None = None
|
|
127
|
+
refused: DevRefusal | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# Silence a participant for the rest of the call: their audio leaves the room, for everyone in it.
|
|
131
|
+
# Lands as track.unpublished for their microphone. There is no unmute; a leg that must speak again
|
|
132
|
+
# is invited again.
|
|
133
|
+
class ParticipantMute(WireModel):
|
|
134
|
+
"""Silence a participant for the rest of the call."""
|
|
135
|
+
|
|
136
|
+
identity: str
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# Lands as participant.left with reason participant_removed. Removing the caller ends the call.
|
|
140
|
+
class ParticipantRemove(WireModel):
|
|
141
|
+
"""Put a participant out of the room."""
|
|
142
|
+
|
|
143
|
+
identity: str
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Ping(WireModel):
|
|
147
|
+
"""Is the socket alive? The gateway answers pong."""
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# The name must be one of the agent's declared blocks, or one of the default four; anything else is
|
|
151
|
+
# refused with the name.
|
|
152
|
+
class PromptSet(WireModel):
|
|
153
|
+
"""Rewrite one block of the prompt, whole, by name."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
text: str
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# A second SIP leg dialed to a number is the warm path: the agent stays on with the caller while the
|
|
160
|
+
# other side answers. Lands as participant.joined when they arrive, or error when they do not.
|
|
161
|
+
class RoomInvite(WireModel):
|
|
162
|
+
"""Bring somebody else into the call's room."""
|
|
163
|
+
|
|
164
|
+
to: str
|
|
165
|
+
kind: Literal["sip", "participant"]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# Push a payload to a browser in the room over the DataChannel: a card to render, a form to open.
|
|
169
|
+
# Lands as room.sent with the size, never the payload. The widget listens on pinecall.ui; a topic of
|
|
170
|
+
# the tenant's own reaches the tenant's own page code.
|
|
171
|
+
class RoomSend(WireModel):
|
|
172
|
+
"""Push a payload to a browser in the room over the DataChannel."""
|
|
173
|
+
|
|
174
|
+
topic: str
|
|
175
|
+
data: dict[str, Any]
|
|
176
|
+
to: str | None = None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# Set up this one call before the first turn: the app's initial state, and any config that differs
|
|
180
|
+
# from the agent's defaults for this caller.
|
|
181
|
+
class SessionConfigure(WireModel):
|
|
182
|
+
"""Set up this one call before the first turn."""
|
|
183
|
+
|
|
184
|
+
state: dict[str, Any] | None = None
|
|
185
|
+
config: AgentConfig | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# The platform logs state.changed and re-renders.
|
|
189
|
+
class StateSet(WireModel):
|
|
190
|
+
"""The app's state changed and this is all of it."""
|
|
191
|
+
|
|
192
|
+
state: dict[str, Any]
|
|
193
|
+
changed: list[str] | None = None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SupervisorVerb(WireModel):
|
|
197
|
+
"""One supervise verb, from the human the door named."""
|
|
198
|
+
|
|
199
|
+
by: Supervisor
|
|
200
|
+
verb: Verb
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# The full list was declared in agent.configure; this is the subset whose when allows them in this
|
|
204
|
+
# state.
|
|
205
|
+
class ToolsSet(WireModel):
|
|
206
|
+
"""The tools the model may see now."""
|
|
207
|
+
|
|
208
|
+
tools: list[ToolSpec]
|