pinecall 0.0.1
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +167 -0
- data/LICENSE +202 -0
- data/README.md +140 -0
- data/lib/pinecall/agent/author.rb +31 -0
- data/lib/pinecall/agent/config.rb +124 -0
- data/lib/pinecall/agent/doc.rb +73 -0
- data/lib/pinecall/agent/knowledge.rb +18 -0
- data/lib/pinecall/agent/searching.rb +33 -0
- data/lib/pinecall/agent/spec.rb +133 -0
- data/lib/pinecall/agent/state.rb +185 -0
- data/lib/pinecall/agent/tools.rb +119 -0
- data/lib/pinecall/agent.rb +160 -0
- data/lib/pinecall/blocks.rb +152 -0
- data/lib/pinecall/bridge.rb +239 -0
- data/lib/pinecall/call_world/answers.rb +61 -0
- data/lib/pinecall/call_world/room.rb +25 -0
- data/lib/pinecall/call_world.rb +175 -0
- data/lib/pinecall/client/agent.rb +179 -0
- data/lib/pinecall/client/call.rb +140 -0
- data/lib/pinecall/client/connection.rb +190 -0
- data/lib/pinecall/client/endpoints.rb +30 -0
- data/lib/pinecall/client/listeners.rb +42 -0
- data/lib/pinecall/client/observe.rb +109 -0
- data/lib/pinecall/client/rest.rb +61 -0
- data/lib/pinecall/client.rb +212 -0
- data/lib/pinecall/errors.rb +53 -0
- data/lib/pinecall/panel.rb +138 -0
- data/lib/pinecall/reading.rb +37 -0
- data/lib/pinecall/rules.rb +38 -0
- data/lib/pinecall/serve/held.rb +40 -0
- data/lib/pinecall/serve/loading.rb +102 -0
- data/lib/pinecall/serve/viewing.rb +40 -0
- data/lib/pinecall/serve.rb +169 -0
- data/lib/pinecall/testing.rb +190 -0
- data/lib/pinecall/version.rb +6 -0
- data/lib/pinecall/view.rb +70 -0
- data/lib/pinecall/wire/codec.rb +75 -0
- data/lib/pinecall/wire/enums.rb +104 -0
- data/lib/pinecall/wire/errors.rb +12 -0
- data/lib/pinecall/wire/reduce.rb +249 -0
- data/lib/pinecall/wire/registry.rb +168 -0
- data/lib/pinecall/wire/shapes.rb +33 -0
- data/lib/pinecall/wire/shapes_call_events.rb +137 -0
- data/lib/pinecall/wire/shapes_commands.rb +114 -0
- data/lib/pinecall/wire/shapes_config.rb +70 -0
- data/lib/pinecall/wire/shapes_doors.rb +96 -0
- data/lib/pinecall/wire/shapes_events.rb +358 -0
- data/lib/pinecall/wire/shapes_metrics.rb +109 -0
- data/lib/pinecall/wire/shapes_parts.rb +340 -0
- data/lib/pinecall/wire/state.rb +56 -0
- data/lib/pinecall/wire/validate.rb +154 -0
- data/lib/pinecall/wire.rb +43 -0
- data/lib/pinecall.rb +45 -0
- data/sig/pinecall.rbs +453 -0
- metadata +119 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Entries and commands read and written: a log line decoded, a command built and encoded.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Wire
|
|
7
|
+
# One log line. The streamed and stored forms are identical, and `seq` is assigned before the
|
|
8
|
+
# write returns, so two readers never disagree.
|
|
9
|
+
Entry = Data.define(:seq, :ts, :call, :agent, :type, :ephemeral, :data)
|
|
10
|
+
|
|
11
|
+
# One app-to-gateway instruction. The gateway answers with the events it produces, or with an
|
|
12
|
+
# `error` naming the command's id.
|
|
13
|
+
Command = Data.define(:type, :agent, :call, :id, :data)
|
|
14
|
+
|
|
15
|
+
# An entry read as what it means: the wire type, and the payload that type names.
|
|
16
|
+
Event = Data.define(:type, :data)
|
|
17
|
+
|
|
18
|
+
# JSON encoding and decoding. Keys are symbols in Ruby and strings on the wire; both are
|
|
19
|
+
# snake_case, so unlike TypeScript there is no key renaming.
|
|
20
|
+
module Codec
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# One log line from parsed JSON. A shape the protocol does not declare is a WireError.
|
|
24
|
+
def decode_entry(raw)
|
|
25
|
+
fields = symbolize(raw)
|
|
26
|
+
Validate.call!("Entry", fields, where: "entry")
|
|
27
|
+
Entry.new(**fields)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The entry's data as the shape its type names. An unknown type is a WireError.
|
|
31
|
+
def event_of(entry)
|
|
32
|
+
shape = Registry::EVENTS[entry.type]
|
|
33
|
+
raise WireError, "unknown event type: #{entry.type}" if shape.nil?
|
|
34
|
+
|
|
35
|
+
Validate.call!(shape, entry.data, where: entry.type)
|
|
36
|
+
Event.new(type: entry.type, data: entry.data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build a command frame, validating its data and then the whole envelope. Validating here
|
|
40
|
+
# keeps the backtrace in the app's code.
|
|
41
|
+
def command(type:, agent:, data:, call: nil, id: nil)
|
|
42
|
+
shape = Registry::COMMANDS[type]
|
|
43
|
+
raise WireError, "unknown command type: #{type}" if shape.nil?
|
|
44
|
+
|
|
45
|
+
Validate.call!(shape, data, where: type)
|
|
46
|
+
frame = { type:, agent:, call:, data: }
|
|
47
|
+
frame[:id] = id unless id.nil?
|
|
48
|
+
Validate.call!("Command", frame, where: type)
|
|
49
|
+
Command.new(type:, agent:, call:, id:, data:)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A frame as JSON text. A command without an id omits the key (the schema refuses null);
|
|
53
|
+
# `call` stays null, since a command about the agent itself belongs to no call.
|
|
54
|
+
def encode(frame)
|
|
55
|
+
fields = frame.to_h
|
|
56
|
+
fields.delete(:id) if frame.is_a?(Command) && fields[:id].nil?
|
|
57
|
+
JSON.generate(fields)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Whether an entry of this type is one a store may drop and a slow reader may miss.
|
|
61
|
+
def ephemeral?(type)
|
|
62
|
+
Registry::EPHEMERAL_EVENTS.include?(type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# JSON.parse can symbolize keys, but a hand-built Hash may use strings.
|
|
66
|
+
def symbolize(value)
|
|
67
|
+
case value
|
|
68
|
+
when Hash then value.to_h { |key, inner| [key.to_sym, symbolize(inner)] }
|
|
69
|
+
when Array then value.map { |inner| symbolize(inner) }
|
|
70
|
+
else value
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every closed list of the wire this gem reads, as a frozen array.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Wire
|
|
7
|
+
module Enums
|
|
8
|
+
# The door the public came through: a phone call over SIP, the browser widget over WebRTC,
|
|
9
|
+
# or WhatsApp text.
|
|
10
|
+
CHANNEL = %w[phone web whatsapp].freeze
|
|
11
|
+
# Inbound: the public reached the agent. Outbound: the agent reached out (a dial).
|
|
12
|
+
DIRECTION = %w[inbound outbound].freeze
|
|
13
|
+
# How a call is had: spoken in a room, or written. A web call can be either.
|
|
14
|
+
MEDIUM = %w[voice text].freeze
|
|
15
|
+
# Which of the two worlds a key opens, and so which world an agent is held in and a call ran
|
|
16
|
+
# in. A key is issued into one; an agent registered on it and every call it takes carry that
|
|
17
|
+
# one; a door claimed in one is refused to a key of the other. `sandbox` is where things are
|
|
18
|
+
# written and `production` is what the public reaches — and whether a sandbox agent is one
|
|
19
|
+
# PERSON's copy or the team's shared one is not this field: it is whether the key that
|
|
20
|
+
# registered it names a person. Every key issued before the field existed is production.
|
|
21
|
+
ENV = %w[production sandbox].freeze
|
|
22
|
+
# What a console may ask of the process standing in the agent's directory, relayed by the
|
|
23
|
+
# gateway: a written call to the class mounted there (chat), a simulated caller put on the
|
|
24
|
+
# class it holds, its goldens and a suite of them, its knowledge folder pushed or its golden
|
|
25
|
+
# asked, its memory goldens, the panel it draws beside a conversation (view), a call
|
|
26
|
+
# promoted to a candidate file, the drift of the last two windows, and the reproductions a
|
|
27
|
+
# broken run left on that disk. Everything else a console needs is a door of the gateway.
|
|
28
|
+
DEV_VERB = %w[chat.roster chat.start chat.say chat.end view.render simulate.start goldens.roster goldens.run knowledge.roster knowledge.push knowledge.eval memory.roster memory.eval memory.extraction promote.roster promote.write drift.read reproductions.roster reproductions.read].freeze
|
|
29
|
+
# Why the call is over. Who hung up, what failed before anybody could, drained: the platform
|
|
30
|
+
# took the worker down (a deploy, a stop) with the call still on it, or app_detached: the
|
|
31
|
+
# app holding the agent closed its socket mid-call, so nothing was rendering the prompt or
|
|
32
|
+
# answering a tool — both are nobody's fault and neither is an error.
|
|
33
|
+
END_REASON = %w[caller_hung_up agent_hung_up supervisor_ended transferred no_answer busy dial_failed timeout drained app_detached error].freeze
|
|
34
|
+
# Whose action ended the call. platform covers timeouts, errors and a drained worker.
|
|
35
|
+
ENDED_BY = %w[caller agent supervisor platform].freeze
|
|
36
|
+
# What one judge answered about a finished call. Held: the rule held. Broken: it did not,
|
|
37
|
+
# and the reason names the evidence. Deferred: the judge was asked and could not settle it.
|
|
38
|
+
# Skipped: nobody asked it — no model was reachable inside the call's judging budget.
|
|
39
|
+
SCORE_VERDICT = %w[held broken deferred skipped].freeze
|
|
40
|
+
# Cold: the caller is sent on with a REFER on their SIP leg and the call ends here. Warm:
|
|
41
|
+
# the number is dialled into the call's own room, the agent stays on the line until the
|
|
42
|
+
# other side answers and then falls silent; the call ends when either of them hangs up.
|
|
43
|
+
TRANSFER_MODE = %w[cold warm].freeze
|
|
44
|
+
# Which region of the prompt a block lives in: static, before the history, cached by the
|
|
45
|
+
# provider; or dynamic, after the history, replaced every turn. The append-only history in
|
|
46
|
+
# between is never written by the app.
|
|
47
|
+
PROMPT_REGION = %w[static dynamic].freeze
|
|
48
|
+
# How the knowledge base reaches the model: retrieved, the platform runs search itself when
|
|
49
|
+
# the caller's turn ends; or tool, the model calls search when it decides to. Either way the
|
|
50
|
+
# chunks arrive as a tool result.
|
|
51
|
+
DOCS_MODE = %w[retrieved tool].freeze
|
|
52
|
+
# What the platform believes the person on the line is doing right now. The states are the
|
|
53
|
+
# session's own.
|
|
54
|
+
USER_STATE = %w[listening speaking away].freeze
|
|
55
|
+
# What the agent is doing right now, in the session's own words: warming up, waiting,
|
|
56
|
+
# hearing the caller, generating, or playing audio.
|
|
57
|
+
AGENT_STATE = %w[initializing idle listening thinking speaking].freeze
|
|
58
|
+
# Who a participant is to the call: the person the agent serves (over SIP or the widget),
|
|
59
|
+
# the agent itself, a supervisor who took a seat in the room, a listener who only hears, or
|
|
60
|
+
# a second SIP leg that room.invite brought in.
|
|
61
|
+
PARTICIPANT_KIND = %w[caller agent supervisor listener sip].freeze
|
|
62
|
+
# What a track carries: a microphone's audio, a camera's video, or a screen share.
|
|
63
|
+
TRACK_KIND = %w[audio video screen].freeze
|
|
64
|
+
# Where a track comes from, as livekit's TrackSource names it, in lower case.
|
|
65
|
+
TRACK_SOURCE = %w[microphone camera screen_share screen_share_audio unknown].freeze
|
|
66
|
+
# Where an outside fact came from: the tenant's backend over the app socket (app), or a
|
|
67
|
+
# participant's browser over the DataChannel (participant).
|
|
68
|
+
EVENT_SOURCE = %w[app participant].freeze
|
|
69
|
+
# Who may see a field of the app's state: everyone in the call (public), the tenant's own
|
|
70
|
+
# readers (tenant, the default for a field never declared), or nobody without masking (pii).
|
|
71
|
+
VISIBILITY = %w[public tenant pii].freeze
|
|
72
|
+
# Which projection a sink applies before a state or an entry leaves the platform: public for
|
|
73
|
+
# a participant reading its own call, tenant for the tenant's readers. The contract is
|
|
74
|
+
# docs/protocol/projections.md; a client never applies one.
|
|
75
|
+
PROJECTION = %w[public tenant].freeze
|
|
76
|
+
# Where the call is in its life. idle before any call.* entry, which is what an agent's own
|
|
77
|
+
# log looks like.
|
|
78
|
+
CALL_STATUS = %w[idle ringing dialing active ended].freeze
|
|
79
|
+
# Every closed list by the name the schema gave it, for a validator to look up.
|
|
80
|
+
ALL = {
|
|
81
|
+
"Channel" => CHANNEL,
|
|
82
|
+
"Direction" => DIRECTION,
|
|
83
|
+
"Medium" => MEDIUM,
|
|
84
|
+
"Env" => ENV,
|
|
85
|
+
"DevVerb" => DEV_VERB,
|
|
86
|
+
"EndReason" => END_REASON,
|
|
87
|
+
"EndedBy" => ENDED_BY,
|
|
88
|
+
"ScoreVerdict" => SCORE_VERDICT,
|
|
89
|
+
"TransferMode" => TRANSFER_MODE,
|
|
90
|
+
"PromptRegion" => PROMPT_REGION,
|
|
91
|
+
"DocsMode" => DOCS_MODE,
|
|
92
|
+
"UserState" => USER_STATE,
|
|
93
|
+
"AgentState" => AGENT_STATE,
|
|
94
|
+
"ParticipantKind" => PARTICIPANT_KIND,
|
|
95
|
+
"TrackKind" => TRACK_KIND,
|
|
96
|
+
"TrackSource" => TRACK_SOURCE,
|
|
97
|
+
"EventSource" => EVENT_SOURCE,
|
|
98
|
+
"Visibility" => VISIBILITY,
|
|
99
|
+
"Projection" => PROJECTION,
|
|
100
|
+
"CallStatus" => CALL_STATUS
|
|
101
|
+
}.freeze
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The one error of the wire: a frame, an entry or a body that does not fit its shape.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Wire
|
|
7
|
+
# A message did not match the protocol: an unknown type, a bad shape, an undeclared key.
|
|
8
|
+
# Raised where the frame is built or received, so the backtrace points at the caller.
|
|
9
|
+
class WireError < StandardError
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Folds a log into its State, entry by entry, as the runtime's reducer does.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Wire
|
|
7
|
+
# Folds a log into its State, entry by entry. Must match the TypeScript and Python reducers;
|
|
8
|
+
# the golden fixture checks all three.
|
|
9
|
+
module Reduce
|
|
10
|
+
# Error code recorded for an entry the reader cannot decode.
|
|
11
|
+
UNREADABLE = "unreadable"
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# Fold every entry, in the order given, into the state an empty log starts from.
|
|
16
|
+
def reduce(entries)
|
|
17
|
+
entries.reduce(State.initial) { |state, entry| apply(state, entry) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Fold one entry in. A gap with a snapshot replaces the state; every other entry mutates it
|
|
21
|
+
# in place. The seq always advances.
|
|
22
|
+
def apply(state, entry)
|
|
23
|
+
event = begin
|
|
24
|
+
Codec.event_of(entry)
|
|
25
|
+
rescue WireError => why
|
|
26
|
+
return moved(unreadable(state, entry, why), entry)
|
|
27
|
+
end
|
|
28
|
+
following = event.type == "log.gap" ? on_log_gap(state, event.data) : apply_event(state, entry, event)
|
|
29
|
+
moved(following, entry)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Old logs may hold entries in shapes this reader no longer accepts. Record one error and
|
|
33
|
+
# keep folding instead of rejecting the whole call; the Python and TypeScript reducers agree.
|
|
34
|
+
def unreadable(state, entry, why)
|
|
35
|
+
said = why.message.to_s.lines.first.to_s.strip
|
|
36
|
+
state[:errors] << {
|
|
37
|
+
seq: entry.seq,
|
|
38
|
+
code: UNREADABLE,
|
|
39
|
+
message: "#{entry.type} at seq #{entry.seq} is not the shape this reader knows: #{said}"
|
|
40
|
+
}
|
|
41
|
+
state
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Advance the fold to the entry's seq, whatever the entry was.
|
|
45
|
+
def moved(state, entry)
|
|
46
|
+
state[:seq] = entry.seq
|
|
47
|
+
state[:agent] = entry.agent
|
|
48
|
+
state[:call] = entry.call unless entry.call.nil?
|
|
49
|
+
state
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# rubocop:disable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
53
|
+
def apply_event(state, entry, event)
|
|
54
|
+
data = event.data
|
|
55
|
+
case event.type
|
|
56
|
+
# ── the call ──
|
|
57
|
+
when "call.ringing" then the_line(state, data, status: "ringing", direction: "inbound")
|
|
58
|
+
when "call.dialing" then the_line(state, data, status: "dialing", direction: "outbound")
|
|
59
|
+
when "call.started" then started(state, data)
|
|
60
|
+
when "call.ended" then ended(state, data)
|
|
61
|
+
when "call.transferred" then transferred(state, data)
|
|
62
|
+
when "call.line" then state.merge!(held: data[:held], muted: data[:muted])
|
|
63
|
+
when "call.summary" then summarised(state, data)
|
|
64
|
+
# ── the conversation ──
|
|
65
|
+
when "user.state" then state[:user_state] = data[:state]
|
|
66
|
+
when "agent.state" then state[:agent_state] = data[:state]
|
|
67
|
+
when "user.transcript" then state[:live][:user] = data[:final] ? nil : data[:text]
|
|
68
|
+
when "agent.transcript" then state[:live][:agent] = data[:final] ? nil : said_so_far(state[:live][:agent], data)
|
|
69
|
+
when "turn.user" then turn(state, data, "user")
|
|
70
|
+
when "turn.agent" then turn(state, data, "agent")
|
|
71
|
+
when "memory.ops" then state[:memory].concat(data[:ops])
|
|
72
|
+
when "docs.sources" then state[:sources] = data[:sources].dup
|
|
73
|
+
# ── metrics: every block is kept, in order, by kind ──
|
|
74
|
+
when "metrics.llm", "metrics.stt", "metrics.tts", "metrics.vad", "metrics.eou",
|
|
75
|
+
"metrics.eot", "metrics.interruption", "metrics.realtime", "metrics.avatar"
|
|
76
|
+
state[:metrics][event.type.delete_prefix("metrics.").to_sym] << data
|
|
77
|
+
# ── tools, state, confirmation ──
|
|
78
|
+
when "tool.call" then state[:tools] << data.merge(status: "running", seq: entry.seq)
|
|
79
|
+
when "tool.result" then on_tool_result(state, data)
|
|
80
|
+
when "state.changed" then state[:app_state] = data[:state].dup
|
|
81
|
+
when "prompt.changed" then on_prompt_changed(state, data, entry)
|
|
82
|
+
when "tools.changed" then state[:tools_visible] = data[:visible].dup
|
|
83
|
+
when "confirm.request" then requested(state, data)
|
|
84
|
+
when "confirm.granted" then settle(state, data[:call_id], { status: "granted", said: data[:said] })
|
|
85
|
+
when "confirm.declined" then declined(state, data)
|
|
86
|
+
# ── supervision, the agent, markers ──
|
|
87
|
+
when "supervisor.took_over" then state[:handoff] = { active: true, by: data[:by] }
|
|
88
|
+
when "supervisor.released" then state[:handoff] = { active: false, by: nil }
|
|
89
|
+
when "supervisor.transferred" then took_the_line(state, data)
|
|
90
|
+
when "attention.requested" then asked_for_a_person(state, data, entry.ts)
|
|
91
|
+
when "attention.answered" then answered(state, data)
|
|
92
|
+
when "agent.registered" then state[:routes] = data[:routes].dup
|
|
93
|
+
when "error" then state[:errors] << { seq: entry.seq, code: data[:code], message: data[:message] }
|
|
94
|
+
when "custom" then state[:custom] << { seq: entry.seq, name: data[:name], data: data[:data].dup }
|
|
95
|
+
# ── the room and the outside world ──
|
|
96
|
+
when "room.opened" then state[:room] = { name: data[:name], sid: data[:sid], participants: [], caller: nil }
|
|
97
|
+
when "participant.joined" then joined(state, data, entry.ts)
|
|
98
|
+
when "participant.left" then left(state, data[:identity])
|
|
99
|
+
when "participant.speaking" then speaking(state, data)
|
|
100
|
+
when "event.received" then state[:events] << data.except(:data).merge(seq: entry.seq)
|
|
101
|
+
end
|
|
102
|
+
state
|
|
103
|
+
end
|
|
104
|
+
# rubocop:enable Metrics/MethodLength, Metrics/CyclomaticComplexity
|
|
105
|
+
|
|
106
|
+
# Not reduced (the case above leaves the state unchanged): supervisor.said/.whispered surface
|
|
107
|
+
# as turns and prompt changes, supervisor.ended as call.ended; agent.configured, pong,
|
|
108
|
+
# log.caught_up, track.published, track.unpublished and room.sent stay in the log only.
|
|
109
|
+
|
|
110
|
+
def the_line(state, line, status:, direction:)
|
|
111
|
+
state.merge!(
|
|
112
|
+
status:,
|
|
113
|
+
direction:,
|
|
114
|
+
channel: line[:channel],
|
|
115
|
+
from: line[:from],
|
|
116
|
+
to: line[:to],
|
|
117
|
+
caller: line[:caller]
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def started(state, data)
|
|
122
|
+
the_line(state, data, status: "active", direction: data[:direction])
|
|
123
|
+
state[:started_at] = data[:started_at]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def ended(state, data)
|
|
127
|
+
state[:status] = "ended"
|
|
128
|
+
state[:ended_at] = data[:ended_at]
|
|
129
|
+
state[:end_reason] = data[:reason]
|
|
130
|
+
state[:live] = { user: nil, agent: nil }
|
|
131
|
+
# A caller who hung up while waiting for a person was never answered.
|
|
132
|
+
state[:attention] = state[:attention].merge(status: "lapsed") if state[:attention]&.fetch(:status) == "open"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def transferred(state, data)
|
|
136
|
+
state[:transfer] = {
|
|
137
|
+
to: data[:to],
|
|
138
|
+
mode: data[:mode],
|
|
139
|
+
status: data[:ok] ? "done" : "failed",
|
|
140
|
+
by: state[:transfer]&.fetch(:by, nil) || "agent"
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def took_the_line(state, data)
|
|
145
|
+
state[:transfer] = { to: data[:to], mode: data[:mode], status: "requested", by: "supervisor" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def asked_for_a_person(state, data, asked_at)
|
|
149
|
+
state[:attention] = { reason: data[:reason], wait_s: data[:wait_s], status: "open", asked_at: asked_at, by: nil }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def answered(state, data)
|
|
153
|
+
return if state[:attention].nil?
|
|
154
|
+
|
|
155
|
+
state[:attention] = state[:attention].merge(status: data[:ok] ? "answered" : "lapsed", by: data[:by])
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def summarised(state, data)
|
|
159
|
+
state[:usage] = data[:usage].dup
|
|
160
|
+
state[:cost] = data[:cost]
|
|
161
|
+
state[:outcome] = data[:outcome]
|
|
162
|
+
state[:end_reason] ||= data[:reason]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# A delta: one word of a spoken reply or one token of a written one. Aligned voice words
|
|
166
|
+
# arrive bare and are joined with a space; written tokens carry their own spacing.
|
|
167
|
+
def said_so_far(so_far, data)
|
|
168
|
+
return data[:text] if so_far.nil? || so_far.empty?
|
|
169
|
+
|
|
170
|
+
apart = so_far.match?(/\s\z/) || data[:text].match?(/\A\s/)
|
|
171
|
+
!data[:start].nil? && !apart ? "#{so_far} #{data[:text]}" : "#{so_far}#{data[:text]}"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def turn(state, data, who)
|
|
175
|
+
state[:turns] << { role: who }.merge(data)
|
|
176
|
+
state[:live][who.to_sym] = nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def requested(state, data)
|
|
180
|
+
state[:confirms] << {
|
|
181
|
+
tool: data[:tool],
|
|
182
|
+
call_id: data[:call_id],
|
|
183
|
+
audience: data[:audience],
|
|
184
|
+
phrase: data[:phrase],
|
|
185
|
+
status: "pending"
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def declined(state, data)
|
|
190
|
+
verdict = { status: "declined", reason: data[:reason] }
|
|
191
|
+
verdict[:said] = data[:said] if data.key?(:said)
|
|
192
|
+
settle(state, data[:call_id], verdict)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def on_tool_result(state, result)
|
|
196
|
+
at = state[:tools].rindex { |run| run[:call_id] == result[:call_id] }
|
|
197
|
+
return if at.nil?
|
|
198
|
+
|
|
199
|
+
outcome = result.except(:call_id, :name)
|
|
200
|
+
state[:tools][at] = state[:tools][at].merge(outcome, status: outcome.key?(:error) ? "failed" : "done")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def settle(state, call_id, verdict)
|
|
204
|
+
at = state[:confirms].rindex { |confirm| confirm[:call_id] == call_id }
|
|
205
|
+
return if at.nil?
|
|
206
|
+
|
|
207
|
+
state[:confirms][at] = state[:confirms][at].merge(verdict)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def on_prompt_changed(state, data, entry)
|
|
211
|
+
state[:prompt][data[:name].to_sym] = { hash: data[:hash], chars: data[:chars], seq: entry.seq }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# joined_at is the entry's ts; the event does not repeat it.
|
|
215
|
+
def joined(state, joined, at)
|
|
216
|
+
return if state[:room].nil?
|
|
217
|
+
|
|
218
|
+
state[:room][:participants] << joined.merge(joined_at: at, speaking: false)
|
|
219
|
+
state[:room][:caller] = joined[:identity] if joined[:kind] == "caller"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# An identity is unique within a room: LiveKit disconnects the first of two that share one.
|
|
223
|
+
def left(state, identity)
|
|
224
|
+
return if state[:room].nil?
|
|
225
|
+
|
|
226
|
+
state[:room][:participants].reject! { |one| one[:identity] == identity }
|
|
227
|
+
state[:room][:caller] = nil if state[:room][:caller] == identity
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def speaking(state, data)
|
|
231
|
+
who = state[:room]&.fetch(:participants)&.find { |one| one[:identity] == data[:identity] }
|
|
232
|
+
who[:speaking] = data[:speaking] unless who.nil?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# With a snapshot the state is replaced; without one only the gap is recorded.
|
|
236
|
+
def on_log_gap(state, data)
|
|
237
|
+
following = data[:snapshot].nil? ? state : deep_copy(data[:snapshot])
|
|
238
|
+
following[:gaps] << { from_seq: data[:from_seq], to_seq: data[:to_seq] }
|
|
239
|
+
following
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Copy the snapshot so later writes do not mutate the entry. Plain JSON data, so Marshal is a
|
|
243
|
+
# safe deep copy.
|
|
244
|
+
def deep_copy(value)
|
|
245
|
+
Marshal.load(Marshal.dump(value))
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every event and command of the wire by its type, and the shape its data is.
|
|
4
|
+
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module Pinecall
|
|
8
|
+
module Wire
|
|
9
|
+
module Registry
|
|
10
|
+
# The shape each event's data is, by the event's wire type.
|
|
11
|
+
EVENTS = {
|
|
12
|
+
"agent.configured" => "AgentConfigured",
|
|
13
|
+
"agent.detached" => "AgentDetached",
|
|
14
|
+
"agent.draining" => "AgentDraining",
|
|
15
|
+
"agent.registered" => "AgentRegistered",
|
|
16
|
+
"agent.state" => "AgentStateChanged",
|
|
17
|
+
"agent.transcript" => "AgentTranscript",
|
|
18
|
+
"attention.answered" => "AttentionAnswered",
|
|
19
|
+
"attention.requested" => "AttentionRequested",
|
|
20
|
+
"call.attached" => "CallAttached",
|
|
21
|
+
"call.claimed" => "CallClaimed",
|
|
22
|
+
"call.dialing" => "CallDialing",
|
|
23
|
+
"call.ended" => "CallEnded",
|
|
24
|
+
"call.line" => "CallLine",
|
|
25
|
+
"call.ringing" => "CallRinging",
|
|
26
|
+
"call.score" => "CallScore",
|
|
27
|
+
"call.started" => "CallStarted",
|
|
28
|
+
"call.summary" => "CallSummary",
|
|
29
|
+
"call.transferred" => "CallTransferred",
|
|
30
|
+
"callback.requested" => "CallbackRequested",
|
|
31
|
+
"code.claimed" => "CodeClaimed",
|
|
32
|
+
"code.issued" => "CodeIssued",
|
|
33
|
+
"confirm.declined" => "ConfirmDeclined",
|
|
34
|
+
"confirm.granted" => "ConfirmGranted",
|
|
35
|
+
"confirm.request" => "ConfirmRequest",
|
|
36
|
+
"credits.exhausted" => "CreditsExhausted",
|
|
37
|
+
"custom" => "Custom",
|
|
38
|
+
"dev.request" => "DevRequest",
|
|
39
|
+
"docs.sources" => "DocsSources",
|
|
40
|
+
"dtmf.received" => "DtmfReceived",
|
|
41
|
+
"error" => "ErrorEvent",
|
|
42
|
+
"event.received" => "EventReceived",
|
|
43
|
+
"fleet.full" => "FleetFull",
|
|
44
|
+
"log.caught_up" => "LogCaughtUp",
|
|
45
|
+
"log.gap" => "LogGap",
|
|
46
|
+
"memory.ops" => "MemoryOps",
|
|
47
|
+
"message.taken" => "MessageTaken",
|
|
48
|
+
"message.waiting" => "MessageWaiting",
|
|
49
|
+
"metrics.avatar" => "AvatarMetrics",
|
|
50
|
+
"metrics.eot" => "EOTInferenceMetrics",
|
|
51
|
+
"metrics.eou" => "EOUMetrics",
|
|
52
|
+
"metrics.interruption" => "InterruptionMetrics",
|
|
53
|
+
"metrics.llm" => "LLMMetrics",
|
|
54
|
+
"metrics.realtime" => "RealtimeModelMetrics",
|
|
55
|
+
"metrics.stt" => "STTMetrics",
|
|
56
|
+
"metrics.tts" => "TTSMetrics",
|
|
57
|
+
"metrics.vad" => "VADMetrics",
|
|
58
|
+
"participant.joined" => "ParticipantJoined",
|
|
59
|
+
"participant.left" => "ParticipantLeft",
|
|
60
|
+
"participant.speaking" => "ParticipantSpeaking",
|
|
61
|
+
"pong" => "Pong",
|
|
62
|
+
"prompt.changed" => "PromptChanged",
|
|
63
|
+
"room.opened" => "RoomOpened",
|
|
64
|
+
"room.sent" => "RoomSent",
|
|
65
|
+
"spend.unusual" => "SpendUnusual",
|
|
66
|
+
"state.changed" => "StateChanged",
|
|
67
|
+
"supervisor.ended" => "SupervisorEnded",
|
|
68
|
+
"supervisor.released" => "SupervisorReleased",
|
|
69
|
+
"supervisor.said" => "SupervisorSaid",
|
|
70
|
+
"supervisor.took_over" => "SupervisorTookOver",
|
|
71
|
+
"supervisor.transferred" => "SupervisorTransferred",
|
|
72
|
+
"supervisor.whispered" => "SupervisorWhispered",
|
|
73
|
+
"tool.call" => "ToolCall",
|
|
74
|
+
"tool.result" => "ToolResult",
|
|
75
|
+
"tools.changed" => "ToolsChanged",
|
|
76
|
+
"track.published" => "TrackPublished",
|
|
77
|
+
"track.unpublished" => "TrackUnpublished",
|
|
78
|
+
"turn.agent" => "AgentTurnEnded",
|
|
79
|
+
"turn.user" => "UserTurnEnded",
|
|
80
|
+
"user.state" => "UserStateChanged",
|
|
81
|
+
"user.transcript" => "UserTranscript",
|
|
82
|
+
"vendor.switched" => "VendorSwitched"
|
|
83
|
+
}.freeze
|
|
84
|
+
|
|
85
|
+
# The shape each command's data is, by the command's wire type.
|
|
86
|
+
COMMANDS = {
|
|
87
|
+
"agent.configure" => "AgentConfigure",
|
|
88
|
+
"agent.drain" => "AgentDrain",
|
|
89
|
+
"agent.register" => "AgentRegister",
|
|
90
|
+
"agent.reply" => "AgentReply",
|
|
91
|
+
"agent.say" => "AgentSay",
|
|
92
|
+
"call.attention" => "CallAttention",
|
|
93
|
+
"call.callback" => "CallCallback",
|
|
94
|
+
"call.claim" => "CallClaim",
|
|
95
|
+
"call.dial" => "CallDial",
|
|
96
|
+
"call.dtmf" => "CallDtmf",
|
|
97
|
+
"call.event" => "CallEvent",
|
|
98
|
+
"call.hangup" => "CallHangup",
|
|
99
|
+
"call.hold" => "CallHold",
|
|
100
|
+
"call.log" => "CallLog",
|
|
101
|
+
"call.mute" => "CallMute",
|
|
102
|
+
"call.opt_out" => "CallOptOut",
|
|
103
|
+
"call.transfer" => "CallTransfer",
|
|
104
|
+
"call.unhold" => "CallUnhold",
|
|
105
|
+
"call.unmute" => "CallUnmute",
|
|
106
|
+
"dev.answer" => "DevAnswer",
|
|
107
|
+
"participant.mute" => "ParticipantMute",
|
|
108
|
+
"participant.remove" => "ParticipantRemove",
|
|
109
|
+
"ping" => "Ping",
|
|
110
|
+
"prompt.set" => "PromptSet",
|
|
111
|
+
"room.invite" => "RoomInvite",
|
|
112
|
+
"room.send" => "RoomSend",
|
|
113
|
+
"session.configure" => "SessionConfigure",
|
|
114
|
+
"state.set" => "StateSet",
|
|
115
|
+
"supervisor.verb" => "SupervisorVerb",
|
|
116
|
+
"tool.result" => "ToolResult",
|
|
117
|
+
"tools.set" => "ToolsSet"
|
|
118
|
+
}.freeze
|
|
119
|
+
|
|
120
|
+
# Every event this protocol declares, in the schema's own order.
|
|
121
|
+
EVENT_TYPES = %w[agent.configured agent.detached agent.draining agent.registered agent.state agent.transcript attention.answered attention.requested call.attached call.claimed call.dialing call.ended call.line call.ringing call.score call.started call.summary call.transferred callback.requested code.claimed code.issued confirm.declined confirm.granted confirm.request credits.exhausted custom dev.request docs.sources dtmf.received error event.received fleet.full log.caught_up log.gap memory.ops message.taken message.waiting metrics.avatar metrics.eot metrics.eou metrics.interruption metrics.llm metrics.realtime metrics.stt metrics.tts metrics.vad participant.joined participant.left participant.speaking pong prompt.changed room.opened room.sent spend.unusual state.changed supervisor.ended supervisor.released supervisor.said supervisor.took_over supervisor.transferred supervisor.whispered tool.call tool.result tools.changed track.published track.unpublished turn.agent turn.user user.state user.transcript vendor.switched].freeze
|
|
122
|
+
|
|
123
|
+
# Every command an app may send.
|
|
124
|
+
COMMAND_TYPES = %w[agent.configure agent.drain agent.register agent.reply agent.say call.attention call.callback call.claim call.dial call.dtmf call.event call.hangup call.hold call.log call.mute call.opt_out call.transfer call.unhold call.unmute dev.answer participant.mute participant.remove ping prompt.set room.invite room.send session.configure state.set supervisor.verb tool.result tools.set].freeze
|
|
125
|
+
|
|
126
|
+
# Entries a store may drop and a slow reader may miss without harm: the entry's
|
|
127
|
+
# own ephemeral flag defaults to this.
|
|
128
|
+
EPHEMERAL_EVENTS = %w[agent.transcript dev.request log.caught_up log.gap metrics.vad participant.speaking pong room.sent user.transcript].to_set.freeze
|
|
129
|
+
|
|
130
|
+
# The one event that ends a call: after it nothing more is true and the log seals.
|
|
131
|
+
TERMINAL_EVENT = "call.score"
|
|
132
|
+
|
|
133
|
+
# Which events a command lands in the log as, so a caller knows what to wait for.
|
|
134
|
+
PRODUCES = {
|
|
135
|
+
"agent.configure" => %w[agent.configured],
|
|
136
|
+
"agent.drain" => %w[agent.draining],
|
|
137
|
+
"agent.register" => %w[agent.registered],
|
|
138
|
+
"agent.reply" => %w[turn.agent],
|
|
139
|
+
"agent.say" => %w[turn.agent],
|
|
140
|
+
"call.attention" => %w[attention.requested call.line attention.answered],
|
|
141
|
+
"call.callback" => %w[callback.requested],
|
|
142
|
+
"call.claim" => %w[call.claimed],
|
|
143
|
+
"call.dial" => %w[call.dialing],
|
|
144
|
+
"call.dtmf" => [],
|
|
145
|
+
"call.event" => %w[event.received],
|
|
146
|
+
"call.hangup" => %w[call.ended],
|
|
147
|
+
"call.hold" => %w[call.line],
|
|
148
|
+
"call.log" => %w[custom],
|
|
149
|
+
"call.mute" => %w[call.line],
|
|
150
|
+
"call.transfer" => %w[call.transferred],
|
|
151
|
+
"call.unhold" => %w[call.line],
|
|
152
|
+
"call.unmute" => %w[call.line],
|
|
153
|
+
"dev.answer" => [],
|
|
154
|
+
"participant.mute" => %w[track.unpublished],
|
|
155
|
+
"participant.remove" => %w[participant.left],
|
|
156
|
+
"ping" => %w[pong],
|
|
157
|
+
"prompt.set" => %w[prompt.changed],
|
|
158
|
+
"room.invite" => %w[participant.joined],
|
|
159
|
+
"room.send" => %w[room.sent],
|
|
160
|
+
"session.configure" => %w[state.changed agent.configured],
|
|
161
|
+
"state.set" => %w[state.changed],
|
|
162
|
+
"supervisor.verb" => %w[supervisor.said supervisor.whispered supervisor.took_over supervisor.released supervisor.transferred supervisor.ended],
|
|
163
|
+
"tool.result" => %w[tool.result],
|
|
164
|
+
"tools.set" => %w[tools.changed]
|
|
165
|
+
}.freeze
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Every named shape of the wire, as the table Validate walks. A shape is closed: a key nobody
|
|
4
|
+
# declared is refused by name, since it means the gateway speaks a newer wire than this gem.
|
|
5
|
+
|
|
6
|
+
require_relative "shapes_parts"
|
|
7
|
+
require_relative "shapes_call_events"
|
|
8
|
+
require_relative "shapes_events"
|
|
9
|
+
require_relative "shapes_commands"
|
|
10
|
+
require_relative "shapes_doors"
|
|
11
|
+
require_relative "shapes_config"
|
|
12
|
+
require_relative "shapes_metrics"
|
|
13
|
+
|
|
14
|
+
module Pinecall
|
|
15
|
+
module Wire
|
|
16
|
+
module Shapes
|
|
17
|
+
SHAPES = [PARTS, CONFIG, METRICS, CALL_EVENTS, EVENTS, COMMANDS, DOORS].reduce(:merge).freeze
|
|
18
|
+
|
|
19
|
+
# A union is told apart by one field, the way the schema's discriminator says.
|
|
20
|
+
UNIONS = {
|
|
21
|
+
"ModelUsage" => { on: :type, members: %w[LLMModelUsage TTSModelUsage STTModelUsage InterruptionModelUsage EOTModelUsage] }.freeze,
|
|
22
|
+
"Turn" => { on: :role, members: %w[UserTurn AgentTurn] }.freeze,
|
|
23
|
+
"Verb" => { on: :verb, members: %w[SayVerb WhisperVerb TakeoverVerb ReleaseVerb TransferVerb EndVerb] }.freeze,
|
|
24
|
+
"StateCause" => { on: :kind, members: %w[StateCauseTool StateCauseEvent] }.freeze
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
# A map is keyed by names the app chose; every value is the one shape given here.
|
|
28
|
+
MAPS = {
|
|
29
|
+
"PromptState" => { kind: :ref, ref: "PromptBlockState" }.freeze
|
|
30
|
+
}.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|