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,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "call_world/room"
|
|
4
|
+
require_relative "call_world/answers"
|
|
5
|
+
|
|
6
|
+
module Pinecall
|
|
7
|
+
# The call as an agent sees it: room, turns, and the commands it can send.
|
|
8
|
+
#
|
|
9
|
+
# State is reduced from received entries; each verb is one wire command. No LiveKit access by
|
|
10
|
+
# design: a missing capability should become a new command of the wire.
|
|
11
|
+
class CallWorld
|
|
12
|
+
attr_reader :id, :contact, :from, :channel, :medium, :room, :turns, :today, :claimed
|
|
13
|
+
|
|
14
|
+
# `send` puts one command on the wire for this call; `searching` asks the gateway to search for
|
|
15
|
+
# it. Both are supplied by the bridge. `today` is the day the call opened, YYYY-MM-DD. `medium` is
|
|
16
|
+
# spoken (`voice`) or written (`text`); a gateway that does not say gets it from the channel.
|
|
17
|
+
def initialize(id:, contact: nil, from: nil, channel: nil, medium: nil, today: Time.now.strftime("%Y-%m-%d"),
|
|
18
|
+
claimed: nil, searching: nil, &send)
|
|
19
|
+
@id = id
|
|
20
|
+
@contact = contact
|
|
21
|
+
@from = from
|
|
22
|
+
@channel = channel
|
|
23
|
+
@medium = (medium || (channel.to_s == "whatsapp" ? "text" : "voice")).to_s
|
|
24
|
+
@today = today
|
|
25
|
+
@claimed = claimed
|
|
26
|
+
@searching = searching
|
|
27
|
+
@send = send
|
|
28
|
+
@room = { participants: [], caller: nil }
|
|
29
|
+
@turns = []
|
|
30
|
+
@waiting = Waiting.new
|
|
31
|
+
@cause = nil
|
|
32
|
+
@events = 0
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The external event being handled, if any; logged as the cause of state changes.
|
|
36
|
+
attr_accessor :cause
|
|
37
|
+
|
|
38
|
+
# Next per-call event sequence number (not the wire's seq).
|
|
39
|
+
def numbered = @events += 1
|
|
40
|
+
|
|
41
|
+
# ── commands ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
# Speak `text` verbatim now. Returns true once the turn arrives, false after `LANDS_WITHIN_S`.
|
|
44
|
+
def say(text, **options)
|
|
45
|
+
@waiting.wait(:spoken, LANDS_WITHIN_S, false) { @send.call("agent.say", { text: }.merge(options)) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Make the model speak now, guided by instructions the caller does not hear.
|
|
49
|
+
def reply(instructions, **options)
|
|
50
|
+
@waiting.wait(:spoken, LANDS_WITHIN_S, false) { @send.call("agent.reply", { instructions: }.merge(options)) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Search the knowledge bases attached to this agent; the gateway runs it and logs the results.
|
|
54
|
+
# `k` defaults to each base's own setting.
|
|
55
|
+
def search(query, k: nil)
|
|
56
|
+
raise Error, NO_GATEWAY_TO_SEARCH if @searching.nil?
|
|
57
|
+
|
|
58
|
+
@searching.call(query, k).map { |chunk| Found.new(path: chunk[:path], heading: chunk[:heading], text: chunk[:text]) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Transfer the caller to a number: sent on (cold) on a phone call, dialled into the call (warm)
|
|
62
|
+
# in a browser; `mode:` forces one. `ok: false` means the caller is still with the agent.
|
|
63
|
+
def transfer(to, mode: nil)
|
|
64
|
+
wanted = mode.nil? ? { to: } : { to:, mode: mode.to_s }
|
|
65
|
+
lapsed = Transferred.new(to:, mode: nil, ok: false, error: NO_ANSWER)
|
|
66
|
+
@waiting.wait(:transfers, TRANSFER_WITHIN_S, lapsed) { @send.call("call.transfer", wanted) }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Ask for a supervisor; the caller waits until someone takes the line or `wait_s` passes.
|
|
70
|
+
# `reason` is shown to the supervisor. The calling tool runs the whole time.
|
|
71
|
+
def attention(reason, wait_s:)
|
|
72
|
+
lapsed = Attended.new(ok: false, by: nil, error: NO_ANSWER)
|
|
73
|
+
@waiting.wait(:asks, wait_s + A_MOMENT_S, lapsed) { @send.call("call.attention", { reason:, wait_s: }) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Put the caller on hold: they hear hold music and the agent neither speaks nor listens.
|
|
77
|
+
def hold = @send.call("call.hold", {})
|
|
78
|
+
|
|
79
|
+
# Take the caller off hold.
|
|
80
|
+
def unhold = @send.call("call.unhold", {})
|
|
81
|
+
|
|
82
|
+
# Send DTMF tones: `0-9`, `*`, `#`, and `,` for a pause.
|
|
83
|
+
def dtmf(digits) = @send.call("call.dtmf", { digits: })
|
|
84
|
+
|
|
85
|
+
# Bind this call to the four-digit code shown on the caller's page; `claimed` is set when the
|
|
86
|
+
# log says it took. A code that is not four digits never reaches the wire.
|
|
87
|
+
def claim(code) = @send.call("call.claim", { code: })
|
|
88
|
+
|
|
89
|
+
# Record a callback request in the call's log for the backend to dial. `at:` is the wire's `when`.
|
|
90
|
+
def callback(number, at: nil, note: nil)
|
|
91
|
+
@send.call("call.callback", { number:, when: at, note: }.compact)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Send a payload to browsers in the room. The log records its size, not its content.
|
|
95
|
+
def send_to(topic, data, to: nil)
|
|
96
|
+
payload = { topic:, data: }
|
|
97
|
+
payload[:to] = Array(to) unless to.nil?
|
|
98
|
+
@send.call("room.send", payload)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A participant handle by identity.
|
|
102
|
+
def participant(identity) = Seat.new(identity, @send)
|
|
103
|
+
|
|
104
|
+
# Invite a second SIP leg or a person.
|
|
105
|
+
def invite(to, kind: nil)
|
|
106
|
+
wanted = { to: }
|
|
107
|
+
wanted[:kind] = kind.to_s unless kind.nil?
|
|
108
|
+
@send.call("room.invite", wanted)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Append an application entry to the call's log.
|
|
112
|
+
def log(name, data = {})
|
|
113
|
+
@send.call("call.log", { name: name.to_s, data: data.is_a?(Hash) ? data : { value: data } })
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The caller asked never to be called again: their number joins the org's do-not-call list, and no
|
|
117
|
+
# call of the org reaches it until a consent is recorded. Tell them it is done.
|
|
118
|
+
def opt_out(note = nil)
|
|
119
|
+
@send.call("call.opt_out", note.nil? ? {} : { note: })
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# End the call; `call.ended` follows with reason `agent_hung_up`.
|
|
123
|
+
def hangup(reason = nil)
|
|
124
|
+
@send.call("call.hangup", reason.nil? ? {} : { reason: })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# ── entries ──────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
# Apply one log entry to the room, the turns and the verbs waiting on it.
|
|
130
|
+
def take(type, data, at)
|
|
131
|
+
case type
|
|
132
|
+
when "participant.joined" then joined(data, at)
|
|
133
|
+
when "participant.left" then @room[:participants].reject! { |one| one.identity == data[:identity] }
|
|
134
|
+
when "participant.speaking" then speaking(data)
|
|
135
|
+
when "turn.user" then remember("user", data, at)
|
|
136
|
+
when "turn.agent" then remember("agent", data, at).tap { @waiting.settle(:spoken, true) }
|
|
137
|
+
when "call.claimed" then @claimed = data[:code].to_s
|
|
138
|
+
when "call.transferred" then @waiting.settle(:transfers, transferred(data))
|
|
139
|
+
when "attention.answered" then @waiting.settle(:asks, Attended.new(ok: data[:ok] == true, by: data[:by], error: data[:error]))
|
|
140
|
+
when "call.ended" then @waiting.end_all
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def participants = @room[:participants].dup
|
|
145
|
+
|
|
146
|
+
# The caller's participant, or nil.
|
|
147
|
+
def caller_seat = @room[:participants].find { |one| one.kind == "caller" }
|
|
148
|
+
|
|
149
|
+
def last_turn = @turns.last
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def joined(data, at)
|
|
154
|
+
@room[:participants] << Participant.new(
|
|
155
|
+
identity: data[:identity], kind: data[:kind], name: data[:name], joined_at: at, speaking: false
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def speaking(data)
|
|
160
|
+
who = @room[:participants].find { |one| one.identity == data[:identity] }
|
|
161
|
+
who.speaking = data[:speaking] unless who.nil?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def remember(who, data, at)
|
|
165
|
+
turn = Turn.new(who:, text: data[:text], speech_id: data[:speech_id],
|
|
166
|
+
interrupted: data[:interrupted] || false, at:)
|
|
167
|
+
@turns << turn
|
|
168
|
+
turn
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def transferred(data)
|
|
172
|
+
Transferred.new(to: data[:to].to_s, mode: data[:mode], ok: data[:ok] == true, error: data[:error])
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# The socket-level handle for one agent slug: its declaration, tool runners and listeners.
|
|
6
|
+
# (`Pinecall::Agent` is the class a developer writes; `Bridge` connects the two.)
|
|
7
|
+
#
|
|
8
|
+
# The gateway's registry is in memory, so the declaration is re-sent on every reconnect.
|
|
9
|
+
# Several sockets may hold one slug; unclaimed calls go to the newest that `takes_unclaimed`,
|
|
10
|
+
# so a rolling deploy serves immediately and a console never gets calls it did not open.
|
|
11
|
+
class Agent
|
|
12
|
+
ANSWERS_WITHIN_S = 10
|
|
13
|
+
|
|
14
|
+
NO_DEV_HANDLER = "this process answers no dev verbs: it is not a `pinecall start` in the agent's directory"
|
|
15
|
+
|
|
16
|
+
attr_reader :slug, :calls, :config, :app
|
|
17
|
+
|
|
18
|
+
def initialize(slug, options, client)
|
|
19
|
+
@slug = slug
|
|
20
|
+
@client = client
|
|
21
|
+
@routes = options[:routes] || []
|
|
22
|
+
@takes_unclaimed = options.fetch(:takes_unclaimed, true)
|
|
23
|
+
@tools = {}
|
|
24
|
+
@waiting = []
|
|
25
|
+
@config = options.reject { |key, _| %i[routes tools takes_unclaimed].include?(key) }
|
|
26
|
+
@app = nil
|
|
27
|
+
@running = []
|
|
28
|
+
@dev = nil
|
|
29
|
+
@lock = Mutex.new
|
|
30
|
+
declare(options[:tools] || [])
|
|
31
|
+
@calls = CallBook.new(self)
|
|
32
|
+
@listeners = Listeners.new { |error| on_error(error) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Listen for one event type across this agent's calls.
|
|
36
|
+
def on(type, &listener) = @listeners.on(type, &listener)
|
|
37
|
+
|
|
38
|
+
# Listen for every event of this agent.
|
|
39
|
+
def on_any(&listener) = @listeners.on_any(&listener)
|
|
40
|
+
|
|
41
|
+
# Answer the console's `dev.request` asks: the block takes the verb and its data and returns the
|
|
42
|
+
# answer, or raises `DevRefused` to refuse with a status. Without one every ask is a 501.
|
|
43
|
+
def on_dev(&handler)
|
|
44
|
+
@dev = handler
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Replace the tools; sent with the next `configure`.
|
|
48
|
+
#
|
|
49
|
+
# A tool is `{ **spec, run: ->(arguments, call) { … } }`.
|
|
50
|
+
def declare(tools)
|
|
51
|
+
@tools = tools.to_h { |tool| [tool[:name].to_s, tool] }
|
|
52
|
+
@config = @config.merge(tools: tools.map { |tool| tool.reject { |key, _| key == :run } })
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Update the agent config. Only the given fields change; live calls keep their session.
|
|
56
|
+
def configure(changes = {})
|
|
57
|
+
@config = @config.merge(changes)
|
|
58
|
+
ask("agent.configure", lands_as: "agent.configured", data: { config: @config })
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Register the slug and send the config. Runs on every reconnect.
|
|
62
|
+
def open
|
|
63
|
+
registered = ask("agent.register", lands_as: "agent.registered", data: {
|
|
64
|
+
routes: @routes, sdk: @client.sdk, host: Socket.gethostname,
|
|
65
|
+
takes_unclaimed: @takes_unclaimed
|
|
66
|
+
})
|
|
67
|
+
# Each connection gets a new app id.
|
|
68
|
+
@app = registered[:app]
|
|
69
|
+
configure
|
|
70
|
+
self
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ── called by the client ───────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Apply an entry to its call and notify listeners.
|
|
76
|
+
def take(entry)
|
|
77
|
+
event = Wire.event_of(entry)
|
|
78
|
+
call = entry.call.nil? ? nil : @calls.of(entry.call, entry.ts)
|
|
79
|
+
settle(event)
|
|
80
|
+
call&.take(event)
|
|
81
|
+
@listeners.emit(event, call)
|
|
82
|
+
@client.seen(event, call)
|
|
83
|
+
return answer_the_console(event.data) if event.type == "dev.request"
|
|
84
|
+
return if call.nil?
|
|
85
|
+
|
|
86
|
+
run_tool(event, call) if event.type == "tool.call"
|
|
87
|
+
@calls.forget(call)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def command(type, call, data)
|
|
91
|
+
@client.send_command(type:, agent: @slug, call:, data:, id: "#{@slug}:#{type}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def on_error(error) = @client.on_error(error)
|
|
95
|
+
|
|
96
|
+
# Agent-scoped heartbeat; answered with `pong`.
|
|
97
|
+
def ping = command("ping", nil, {})
|
|
98
|
+
|
|
99
|
+
# Stop taking new calls and hand the live ones to another holder, or park them for the next.
|
|
100
|
+
# Running tools still answer. Returns where the calls went: `{ handed:, parked: }`.
|
|
101
|
+
def drain(answer_s: ANSWERS_WITHIN_S) = ask("agent.drain", lands_as: "agent.draining", data: {}, within_s: answer_s)
|
|
102
|
+
|
|
103
|
+
# How many tool runs have not sent their `tool.result` yet.
|
|
104
|
+
def in_flight = @lock.synchronize { @running.count(&:alive?) }
|
|
105
|
+
|
|
106
|
+
# Wait until every running tool has answered, or `within_s` passed.
|
|
107
|
+
def settled(within_s: nil)
|
|
108
|
+
deadline = within_s && (now + within_s)
|
|
109
|
+
@lock.synchronize { @running.dup }.each { |thread| thread.join(deadline && [deadline - now, 0].max) }
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
# Always answer with exactly one `tool.result`: without one the turn waits forever.
|
|
116
|
+
# Runs on its own thread so a slow tool does not block the socket reader.
|
|
117
|
+
def run_tool(event, call)
|
|
118
|
+
call_id, name, arguments = event.data.values_at(:call_id, :name, :arguments)
|
|
119
|
+
tool = @tools[name.to_s]
|
|
120
|
+
return call.tool_result({ call_id:, name:, error: "this app declares no tool called #{name}" }) if tool.nil?
|
|
121
|
+
|
|
122
|
+
running = Thread.new do
|
|
123
|
+
started = now
|
|
124
|
+
begin
|
|
125
|
+
output = tool[:run].call(arguments || {}, call)
|
|
126
|
+
call.tool_result({ call_id:, name:, output:, duration_s: now - started })
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
# Report the exception to the model as the tool's `error`.
|
|
129
|
+
call.tool_result({ call_id:, name:, error: e.message, duration_s: now - started })
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
@lock.synchronize { (@running << running).select!(&:alive?) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Every ask needs one answer, or the console's request hangs until the gateway gives up. On a
|
|
136
|
+
# thread of its own: a panel reads the tenant's systems, and this is the socket's reader.
|
|
137
|
+
def answer_the_console(asked)
|
|
138
|
+
id, verb, data = asked.values_at(:id, :verb, :data)
|
|
139
|
+
Thread.new do
|
|
140
|
+
raise DevRefused.new(501, NO_DEV_HANDLER) if @dev.nil?
|
|
141
|
+
|
|
142
|
+
command("dev.answer", nil, { id:, result: @dev.call(verb, data || {}) })
|
|
143
|
+
rescue DevRefused => e
|
|
144
|
+
command("dev.answer", nil, { id:, refused: { status: e.status, detail: e.detail } })
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
command("dev.answer", nil, { id:, refused: { status: 500, detail: e.message } })
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Await the reply event, or an `error` carrying our id. Only register/configure are awaited;
|
|
151
|
+
# other commands are fire-and-forget.
|
|
152
|
+
def ask(type, lands_as:, data:, within_s: ANSWERS_WITHIN_S)
|
|
153
|
+
id = "#{@slug}:#{type}"
|
|
154
|
+
answer = Thread::Queue.new
|
|
155
|
+
@waiting << { type: lands_as, id:, answer: }
|
|
156
|
+
@client.send_command(type:, agent: @slug, call: nil, data:, id:)
|
|
157
|
+
settled = answer.pop(timeout: within_s)
|
|
158
|
+
raise NotConnected, "#{type}: the gateway did not answer in #{within_s}s" if settled.nil?
|
|
159
|
+
raise settled if settled.is_a?(Exception)
|
|
160
|
+
|
|
161
|
+
settled
|
|
162
|
+
ensure
|
|
163
|
+
@waiting.reject! { |one| one[:answer].equal?(answer) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def settle(event)
|
|
167
|
+
@waiting.dup.each do |waiting|
|
|
168
|
+
if event.type == waiting[:type]
|
|
169
|
+
waiting[:answer].push(event.data)
|
|
170
|
+
elsif event.type == "error" && event.data[:id] == waiting[:id]
|
|
171
|
+
waiting[:answer].push(Refused.new(event.data))
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# A call being served: line details read from the log, and the commands it can send.
|
|
6
|
+
#
|
|
7
|
+
# Commands are not awaited; their effects arrive later as log entries.
|
|
8
|
+
class Call
|
|
9
|
+
attr_reader :id, :today, :claimed, :run, :medium
|
|
10
|
+
attr_accessor :status, :channel, :from, :to, :contact, :state
|
|
11
|
+
|
|
12
|
+
def initialize(id, agent, opened_at)
|
|
13
|
+
@id = id
|
|
14
|
+
@agent = agent
|
|
15
|
+
@today = Time.at(opened_at).strftime("%Y-%m-%d")
|
|
16
|
+
@status = "ringing"
|
|
17
|
+
@channel = nil
|
|
18
|
+
# Spoken or written, as `call.started` says; nil before it, and from a gateway that does not say.
|
|
19
|
+
@medium = nil
|
|
20
|
+
@from = nil
|
|
21
|
+
@to = nil
|
|
22
|
+
@contact = nil
|
|
23
|
+
@claimed = nil
|
|
24
|
+
@run = nil
|
|
25
|
+
@state = {}
|
|
26
|
+
@listeners = Listeners.new { |error| agent.on_error(error) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def agent_slug = @agent.slug
|
|
30
|
+
|
|
31
|
+
# Listen for one event type on this call; the returned lambda unsubscribes.
|
|
32
|
+
def on(type, &listener) = @listeners.on(type, &listener)
|
|
33
|
+
|
|
34
|
+
# Listen for every event on this call.
|
|
35
|
+
def on_any(&listener) = @listeners.on_any(&listener)
|
|
36
|
+
|
|
37
|
+
# ── commands ───────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
# Speak `text` verbatim now, as a `turn.agent`.
|
|
40
|
+
def say(text, **options) = command("agent.say", { text: }.merge(options))
|
|
41
|
+
|
|
42
|
+
# Make the model speak now, guided by instructions the caller does not hear.
|
|
43
|
+
def reply(instructions, **options) = command("agent.reply", { instructions: }.merge(options))
|
|
44
|
+
|
|
45
|
+
# Replace one prompt block by name.
|
|
46
|
+
def set_prompt(name, text) = command("prompt.set", { name: name.to_s, text: })
|
|
47
|
+
|
|
48
|
+
# Set the tools currently visible to the model (a subset of the declared ones).
|
|
49
|
+
def set_tools(tools) = command("tools.set", { tools: })
|
|
50
|
+
|
|
51
|
+
# Send the full state; answered with `state.changed`.
|
|
52
|
+
def set_state(state, changed = nil)
|
|
53
|
+
@state = state
|
|
54
|
+
wire = { state: }
|
|
55
|
+
wire[:changed] = Array(changed).map(&:to_s) unless changed.nil?
|
|
56
|
+
command("state.set", wire)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tool_result(result) = command("tool.result", result)
|
|
60
|
+
|
|
61
|
+
# Send an event from the application's backend; the agent must declare `name`.
|
|
62
|
+
def event(name, data) = command("call.event", { name: name.to_s, data: })
|
|
63
|
+
|
|
64
|
+
# End the call; `call.ended` follows with reason `agent_hung_up`.
|
|
65
|
+
def hangup(reason = nil) = command("call.hangup", reason.nil? ? {} : { reason: })
|
|
66
|
+
|
|
67
|
+
# Append an application entry to the call's log, as a `custom` entry.
|
|
68
|
+
def log(name, data = {}) = command("call.log", { name: name.to_s, data: })
|
|
69
|
+
|
|
70
|
+
# Send any call-scoped command of the wire.
|
|
71
|
+
def command(type, data) = @agent.command(type, @id, data)
|
|
72
|
+
|
|
73
|
+
# ── entries ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Apply an event to the call, then notify listeners.
|
|
76
|
+
def take(event)
|
|
77
|
+
learn(event)
|
|
78
|
+
@listeners.emit(event, self)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def learn(event)
|
|
84
|
+
case event.type
|
|
85
|
+
when "call.ringing" then the_line(event.data, "ringing")
|
|
86
|
+
when "call.dialing" then the_line(event.data, "dialing")
|
|
87
|
+
when "call.started" then started(event.data)
|
|
88
|
+
when "call.attached" then attached(event.data)
|
|
89
|
+
when "call.ended" then @status = "ended"
|
|
90
|
+
when "state.changed" then @state = event.data[:state].dup
|
|
91
|
+
when "call.claimed" then @claimed = event.data[:code]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Handed over mid-conversation: the call's own start, the state it is in, the code it claimed.
|
|
96
|
+
def attached(data)
|
|
97
|
+
line = data[:started]
|
|
98
|
+
started(line)
|
|
99
|
+
@state = data[:state].dup
|
|
100
|
+
@today = Time.at(line[:started_at]).strftime("%Y-%m-%d")
|
|
101
|
+
@claimed = data[:claimed]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def started(line)
|
|
105
|
+
the_line(line, "active")
|
|
106
|
+
@medium = line[:medium]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def the_line(line, status)
|
|
110
|
+
@status = status
|
|
111
|
+
@channel = line[:channel]
|
|
112
|
+
@from = line[:from]
|
|
113
|
+
@to = line[:to]
|
|
114
|
+
@contact = line[:caller]
|
|
115
|
+
@run = line[:run]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Live calls by id; a call is dropped once it has ended.
|
|
120
|
+
class CallBook
|
|
121
|
+
def initialize(agent)
|
|
122
|
+
@agent = agent
|
|
123
|
+
@live = {}
|
|
124
|
+
@lock = Mutex.new
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def live = @lock.synchronize { @live.values.dup }
|
|
128
|
+
|
|
129
|
+
# Find or create the call for an entry.
|
|
130
|
+
def of(id, at)
|
|
131
|
+
@lock.synchronize { @live[id] ||= Call.new(id, @agent, at) }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Drop an ended call; `call.ended` listeners have already run.
|
|
135
|
+
def forget(call)
|
|
136
|
+
@lock.synchronize { @live.delete(call.id) } if call.status == "ended"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# The gateway websocket: auth, reconnect with backoff, heartbeat, and one reader thread.
|
|
6
|
+
#
|
|
7
|
+
# The key goes in the `Authorization` header, never the URL (URLs end up in access logs).
|
|
8
|
+
# A bad key closes with 1008 and no body. `websocket-driver` handles framing over a plain
|
|
9
|
+
# TCP/TLS socket, so no event loop is needed.
|
|
10
|
+
class Connection
|
|
11
|
+
FIRST_WAIT_S = 0.5
|
|
12
|
+
LONGEST_WAIT_S = 30.0
|
|
13
|
+
GROWS_BY = 2
|
|
14
|
+
PINGS_EVERY_S = 30
|
|
15
|
+
|
|
16
|
+
Handlers = Data.define(:on_open, :on_entry, :on_error, :on_heartbeat)
|
|
17
|
+
|
|
18
|
+
attr_reader :url
|
|
19
|
+
|
|
20
|
+
def initialize(url:, api_key:, handlers:, env: nil, ping_every: PINGS_EVERY_S, backoff: {})
|
|
21
|
+
@url = Endpoints.apps(url)
|
|
22
|
+
@api_key = api_key
|
|
23
|
+
@env = env
|
|
24
|
+
@handlers = handlers
|
|
25
|
+
@ping_every = ping_every
|
|
26
|
+
@backoff = { first: FIRST_WAIT_S, longest: LONGEST_WAIT_S, grows_by: GROWS_BY }.merge(backoff)
|
|
27
|
+
@lock = Mutex.new
|
|
28
|
+
@open = false
|
|
29
|
+
@closed = false
|
|
30
|
+
@attempt = 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def open? = @open
|
|
34
|
+
|
|
35
|
+
# Connect and return once `on_open` has run; reconnects are handled internally afterwards.
|
|
36
|
+
def start
|
|
37
|
+
@closed = false
|
|
38
|
+
@opened = Thread::Queue.new
|
|
39
|
+
@reader = Thread.new { dial_until_closed }
|
|
40
|
+
@reader.abort_on_exception = false
|
|
41
|
+
answer = @opened.pop(timeout: 30)
|
|
42
|
+
# Later failures go to `on_error`, not to this caller.
|
|
43
|
+
@opened = nil
|
|
44
|
+
raise NotConnected, "the gateway did not answer in 30s: #{@url}" if answer.nil?
|
|
45
|
+
raise answer if answer.is_a?(Exception)
|
|
46
|
+
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Raises when disconnected instead of queueing: a queued command could land after its call ended.
|
|
51
|
+
def send_frame(command)
|
|
52
|
+
@lock.synchronize do
|
|
53
|
+
raise NotConnected, "#{command.type}: the gateway is not connected" unless @open
|
|
54
|
+
|
|
55
|
+
@driver.text(Wire.encode(command))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A drain was asked: the gateway closing the socket from now on is not dialled back.
|
|
60
|
+
def leaving! = @leaving = true
|
|
61
|
+
|
|
62
|
+
# Close without reconnecting.
|
|
63
|
+
def close
|
|
64
|
+
@closed = true
|
|
65
|
+
@open = false
|
|
66
|
+
@heartbeat&.kill
|
|
67
|
+
@socket&.close
|
|
68
|
+
# A stop is read on the reader thread itself, which cannot wait for its own end.
|
|
69
|
+
@reader&.join(1) unless Thread.current.equal?(@reader)
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Called by websocket-driver.
|
|
74
|
+
def write(bytes)
|
|
75
|
+
@socket.write(bytes)
|
|
76
|
+
rescue IOError, SystemCallError => e
|
|
77
|
+
@handlers.on_error.call(e)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def dial_until_closed
|
|
83
|
+
until @closed
|
|
84
|
+
begin
|
|
85
|
+
dial
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
fail_the_first_dial(e)
|
|
88
|
+
end
|
|
89
|
+
break if @closed || @leaving
|
|
90
|
+
|
|
91
|
+
sleep(wait_s)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def dial
|
|
96
|
+
@socket = connect_to(URI.parse(@url))
|
|
97
|
+
@driver = WebSocket::Driver.client(self)
|
|
98
|
+
@driver.set_header("Authorization", "Bearer #{@api_key}")
|
|
99
|
+
@driver.set_header(Rest::ENV_HEADER, @env) unless @env.nil?
|
|
100
|
+
@driver.on(:open) { opened }
|
|
101
|
+
@driver.on(:message) { |event| took(event.data) }
|
|
102
|
+
@driver.on(:close) { |event| shut(event) }
|
|
103
|
+
@driver.start
|
|
104
|
+
read_until_closed
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def connect_to(url)
|
|
108
|
+
port = url.port || (url.scheme == "wss" ? 443 : 80)
|
|
109
|
+
socket = TCPSocket.new(url.host, port)
|
|
110
|
+
return socket unless url.scheme == "wss"
|
|
111
|
+
|
|
112
|
+
context = OpenSSL::SSL::SSLContext.new
|
|
113
|
+
context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
114
|
+
tls = OpenSSL::SSL::SSLSocket.new(socket, context)
|
|
115
|
+
tls.hostname = url.host
|
|
116
|
+
tls.sync_close = true
|
|
117
|
+
tls.connect
|
|
118
|
+
tls
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def read_until_closed
|
|
122
|
+
loop do
|
|
123
|
+
bytes = @socket.readpartial(4096)
|
|
124
|
+
@driver.parse(bytes)
|
|
125
|
+
end
|
|
126
|
+
rescue EOFError, IOError, SystemCallError, OpenSSL::SSL::SSLError
|
|
127
|
+
@open = false
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Registration runs on its own thread: this is the reader thread, and `agent.register` is
|
|
131
|
+
# answered by an entry it has to read.
|
|
132
|
+
def opened
|
|
133
|
+
@open = true
|
|
134
|
+
@attempt = 0
|
|
135
|
+
@heartbeat = Thread.new { beat }
|
|
136
|
+
Thread.new { declare }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def declare
|
|
140
|
+
@handlers.on_open.call
|
|
141
|
+
@opened&.push(:open)
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
# First connection: raise from `start`. Reconnect: report via `on_error`.
|
|
144
|
+
@opened.nil? || @opened.closed? ? @handlers.on_error.call(e) : @opened.push(e)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def took(raw)
|
|
148
|
+
@handlers.on_entry.call(Wire.decode_entry(JSON.parse(raw, symbolize_names: true)))
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
@handlers.on_error.call(e)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def shut(event)
|
|
154
|
+
@open = false
|
|
155
|
+
@heartbeat&.kill
|
|
156
|
+
return if @closed
|
|
157
|
+
|
|
158
|
+
# Only a failed first connection is reported to `start`; later closes just retry.
|
|
159
|
+
return unless @opened && !@opened.closed? && @attempt.zero?
|
|
160
|
+
|
|
161
|
+
@opened.push(NotConnected.new("the gateway refused the socket: closed with #{event.code}"))
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def fail_the_first_dial(error)
|
|
165
|
+
@open = false
|
|
166
|
+
return @handlers.on_error.call(error) unless @opened && !@opened.closed? && @attempt.zero?
|
|
167
|
+
|
|
168
|
+
@opened.push(NotConnected.new("#{@url}: #{error.message}"))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def beat
|
|
172
|
+
loop do
|
|
173
|
+
sleep(@ping_every)
|
|
174
|
+
break unless @open
|
|
175
|
+
|
|
176
|
+
@handlers.on_heartbeat.call
|
|
177
|
+
end
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
@handlers.on_error.call(e)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Full jitter, so clients do not reconnect in lockstep.
|
|
183
|
+
def wait_s
|
|
184
|
+
window = [@backoff[:longest], @backoff[:first] * (@backoff[:grows_by]**@attempt)].min
|
|
185
|
+
@attempt += 1
|
|
186
|
+
rand * window
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|