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,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# Gateway URLs built from the configured base URL.
|
|
6
|
+
module Endpoints
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# `WS /v1/apps`; an http(s) base is converted to ws(s).
|
|
10
|
+
def apps(base) = door(base, "/v1/apps", websocket: true)
|
|
11
|
+
|
|
12
|
+
# `GET /v1/calls/{id}/events`: a call's log, as JSON or SSE.
|
|
13
|
+
def call_log(base, call) = door(base, "/v1/calls/#{CGI.escape(call)}/events")
|
|
14
|
+
|
|
15
|
+
# `GET /v1/agents/{slug}/calls`: the agent's log (registrations, configs, errors).
|
|
16
|
+
def agent_log(base, agent) = door(base, "/v1/agents/#{CGI.escape(agent)}/calls")
|
|
17
|
+
|
|
18
|
+
# `POST /v1/calls/{id}/lookup`: a platform tool run for one call this app serves.
|
|
19
|
+
def lookup(base, call) = door(base, "/v1/calls/#{CGI.escape(call)}/lookup")
|
|
20
|
+
|
|
21
|
+
def door(base, path, websocket: false)
|
|
22
|
+
url = URI.parse(base)
|
|
23
|
+
secure = %w[https wss].include?(url.scheme)
|
|
24
|
+
url.scheme = websocket ? (secure ? "wss" : "ws") : (secure ? "https" : "http")
|
|
25
|
+
url.path = "#{url.path.chomp("/")}#{path}"
|
|
26
|
+
url.to_s
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# Event listener registry, used per agent and per call. A raising listener is reported to
|
|
6
|
+
# `on_error` and the remaining listeners still run.
|
|
7
|
+
class Listeners
|
|
8
|
+
def initialize(&on_error)
|
|
9
|
+
@by_type = Hash.new { |kept, type| kept[type] = [] }
|
|
10
|
+
@any = []
|
|
11
|
+
@on_error = on_error
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Listen for one event type; the returned lambda unsubscribes.
|
|
16
|
+
def on(type, &listener)
|
|
17
|
+
@lock.synchronize { @by_type[type.to_s] << listener }
|
|
18
|
+
-> { @lock.synchronize { @by_type[type.to_s].delete(listener) } }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def on_any(&listener)
|
|
22
|
+
@lock.synchronize { @any << listener }
|
|
23
|
+
-> { @lock.synchronize { @any.delete(listener) } }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Notify type listeners, then catch-all listeners.
|
|
27
|
+
def emit(event, context)
|
|
28
|
+
for_type, for_any = @lock.synchronize { [@by_type[event.type].dup, @any.dup] }
|
|
29
|
+
for_type.each { |listener| run { listener.call(event.data, context) } }
|
|
30
|
+
for_any.each { |listener| run { listener.call(event, context) } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def run
|
|
36
|
+
yield
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
@on_error.call(e)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# Reads a log as a JSON page or an SSE stream, reduced by the wire's reducer.
|
|
6
|
+
#
|
|
7
|
+
# Reconnects resume with `Last-Event-ID`. A reader that fell behind gets `log.gap` (with a
|
|
8
|
+
# snapshot when available), then `log.caught_up` once the stream is live.
|
|
9
|
+
module Observe
|
|
10
|
+
# One page of entries after the cursor, their reduced state, and the next cursor.
|
|
11
|
+
Page = Data.define(:entries, :state, :live, :next)
|
|
12
|
+
|
|
13
|
+
# One entry, its decoded event, and the reduced state including it.
|
|
14
|
+
Observation = Data.define(:entry, :event, :state)
|
|
15
|
+
|
|
16
|
+
RETRY_S = 1.0
|
|
17
|
+
LONGEST_RETRY_S = 30.0
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Fetch one page after `after`. Use the page's `next`, not the last entry's seq: an empty
|
|
22
|
+
# page of a live log still has one.
|
|
23
|
+
def history(target, url:, api_key:, after: 0)
|
|
24
|
+
answer = get(target, url:, api_key:, after:, accept: "application/json")
|
|
25
|
+
page = JSON.parse(answer.body, symbolize_names: true)
|
|
26
|
+
raise Error, "the log page is not { entries, live, next }" unless page.is_a?(Hash) && page[:entries].is_a?(Array)
|
|
27
|
+
|
|
28
|
+
entries = page[:entries].map { |raw| Wire.decode_entry(raw) }
|
|
29
|
+
Page.new(entries:, state: Wire.reduce(entries), live: page[:live] == true,
|
|
30
|
+
next: page[:next].is_a?(Integer) ? page[:next] : nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Stream entries after `after`, reconnecting as needed. Yields an Observation per entry;
|
|
34
|
+
# returns when the block breaks or `stop` returns true.
|
|
35
|
+
def observe(target, url:, api_key:, after: 0, stop: nil)
|
|
36
|
+
state = Wire.initial_state
|
|
37
|
+
attempt = 0
|
|
38
|
+
opened = false
|
|
39
|
+
until stop&.call
|
|
40
|
+
begin
|
|
41
|
+
stream(target, url:, api_key:, after:) do |entry|
|
|
42
|
+
attempt = 0
|
|
43
|
+
opened = true
|
|
44
|
+
after = entry.seq
|
|
45
|
+
state = Wire.apply(state, entry)
|
|
46
|
+
yield Observation.new(entry:, event: Wire.event_of(entry), state:)
|
|
47
|
+
end
|
|
48
|
+
rescue StandardError
|
|
49
|
+
# Failing before the first entry means misconfiguration: raise. Later drops resume.
|
|
50
|
+
raise unless opened
|
|
51
|
+
end
|
|
52
|
+
attempt += 1
|
|
53
|
+
sleep([LONGEST_RETRY_S, RETRY_S * (2**(attempt - 1))].min * rand)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def stream(target, url:, api_key:, after:)
|
|
58
|
+
get(target, url:, api_key:, after:, accept: "text/event-stream") do |answer|
|
|
59
|
+
buffered = +""
|
|
60
|
+
answer.read_body do |chunk|
|
|
61
|
+
buffered << chunk
|
|
62
|
+
while (cut = buffered.index("\n\n"))
|
|
63
|
+
block = buffered.slice!(0, cut + 2)
|
|
64
|
+
entry = entry_in(block)
|
|
65
|
+
yield entry unless entry.nil?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Parse one SSE block; nil for comments and keep-alives.
|
|
72
|
+
def entry_in(block)
|
|
73
|
+
data = block.lines.filter_map { |line| line.delete_prefix("data:").lstrip.chomp if line.start_with?("data:") }
|
|
74
|
+
return nil if data.empty?
|
|
75
|
+
|
|
76
|
+
Wire.decode_entry(JSON.parse(data.join("\n"), symbolize_names: true))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get(target, url:, api_key:, after:, accept:, &block)
|
|
80
|
+
where = URI.parse(door(target, url))
|
|
81
|
+
where.query = URI.encode_www_form(after:) if after.positive?
|
|
82
|
+
request = Net::HTTP::Get.new(where)
|
|
83
|
+
request["accept"] = accept
|
|
84
|
+
request["authorization"] = "Bearer #{api_key}"
|
|
85
|
+
request["last-event-id"] = after.to_s if after.positive?
|
|
86
|
+
answer(where, request, &block)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def answer(where, request)
|
|
90
|
+
http = Net::HTTP.new(where.host, where.port)
|
|
91
|
+
http.use_ssl = where.scheme == "https"
|
|
92
|
+
http.read_timeout = nil
|
|
93
|
+
http.start do |open|
|
|
94
|
+
open.request(request) do |response|
|
|
95
|
+
raise Error, "#{where.path}: the gateway answered #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
96
|
+
|
|
97
|
+
return block_given? ? yield(response) : response.tap(&:body)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def door(target, url)
|
|
103
|
+
return Endpoints.call_log(url, target[:call]) if target[:call]
|
|
104
|
+
|
|
105
|
+
Endpoints.agent_log(url, target.fetch(:agent))
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Client
|
|
5
|
+
# Authenticated JSON requests to the gateway's REST endpoints. Non-2xx responses raise
|
|
6
|
+
# `Refused` with the gateway's message, never a bare status.
|
|
7
|
+
module Rest
|
|
8
|
+
# The header naming the world a request or a socket acts in.
|
|
9
|
+
ENV_HEADER = "pinecall-env"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def get(url, api_key:, env: nil) = ask(Net::HTTP::Get.new(URI.parse(url)), api_key:, env:)
|
|
14
|
+
|
|
15
|
+
def put(url, body, api_key:, env: nil)
|
|
16
|
+
request = Net::HTTP::Put.new(URI.parse(url))
|
|
17
|
+
request["content-type"] = "application/json"
|
|
18
|
+
request.body = JSON.generate(body)
|
|
19
|
+
ask(request, api_key:, env:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def post(url, body, api_key:, env: nil)
|
|
23
|
+
request = Net::HTTP::Post.new(URI.parse(url))
|
|
24
|
+
request["content-type"] = "application/json"
|
|
25
|
+
request.body = JSON.generate(body)
|
|
26
|
+
ask(request, api_key:, env:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(url, api_key:, env: nil) = ask(Net::HTTP::Delete.new(URI.parse(url)), api_key:, env:)
|
|
30
|
+
|
|
31
|
+
# Returns the body with symbol keys (nil if empty); raises `Refused` on failure. `env` names
|
|
32
|
+
# the world the request acts in (`pinecall-env`); a server's token needs none.
|
|
33
|
+
def ask(request, api_key:, env: nil)
|
|
34
|
+
request["authorization"] = "Bearer #{api_key}"
|
|
35
|
+
request[ENV_HEADER] = env unless env.nil?
|
|
36
|
+
request["accept"] = "application/json"
|
|
37
|
+
where = request.uri
|
|
38
|
+
http = Net::HTTP.new(where.host, where.port)
|
|
39
|
+
http.use_ssl = where.scheme == "https"
|
|
40
|
+
answer = http.request(request)
|
|
41
|
+
body = parsed(answer.body)
|
|
42
|
+
return body if answer.is_a?(Net::HTTPSuccess)
|
|
43
|
+
|
|
44
|
+
raise Refused.new({ code: answer.code, message: sentence(body, answer) })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parsed(text)
|
|
48
|
+
return nil if text.nil? || text.strip.empty?
|
|
49
|
+
|
|
50
|
+
JSON.parse(text, symbolize_names: true)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Gateway refusals use `detail`; otherwise pass the body through.
|
|
56
|
+
def sentence(body, answer)
|
|
57
|
+
(body.is_a?(Hash) && (body[:detail] || body[:message])) || answer.body.to_s.strip
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "socket"
|
|
7
|
+
require "uri"
|
|
8
|
+
require "websocket/driver"
|
|
9
|
+
|
|
10
|
+
require_relative "client/endpoints"
|
|
11
|
+
require_relative "client/listeners"
|
|
12
|
+
require_relative "client/connection"
|
|
13
|
+
require_relative "client/call"
|
|
14
|
+
require_relative "client/agent"
|
|
15
|
+
require_relative "client/observe"
|
|
16
|
+
require_relative "client/rest"
|
|
17
|
+
|
|
18
|
+
module Pinecall
|
|
19
|
+
# A connection to the Pinecall gateway: one socket, its agents, log reads, and a search for a call
|
|
20
|
+
# it serves. `Pinecall::Agent` is built on top of it.
|
|
21
|
+
#
|
|
22
|
+
# pc = Pinecall::Client.new(url: "https://cloud.pinecall.io", api_key: ENV.fetch("PINECALL_KEY"))
|
|
23
|
+
# agent = pc.agent("clinica-norte", tools: [])
|
|
24
|
+
# agent.on("turn.user") { |data, call| call.say("Le he oído: #{data[:text]}") }
|
|
25
|
+
# pc.connect
|
|
26
|
+
#
|
|
27
|
+
# It reads nothing from the environment: the app hands it the gateway and the key it keeps, and
|
|
28
|
+
# `env: "production"` names production for a person's key (a server's token needs none).
|
|
29
|
+
class Client
|
|
30
|
+
# The `sdk` field sent on register.
|
|
31
|
+
def sdk = "pinecall-ruby/#{VERSION}"
|
|
32
|
+
|
|
33
|
+
# A stop from the org: the socket is closed for good and the app decides what comes next.
|
|
34
|
+
STOPPED = "stopped"
|
|
35
|
+
|
|
36
|
+
# Seconds the running tools are given to answer once a drain was asked.
|
|
37
|
+
TOOLS_FINISH_WITHIN_S = 30
|
|
38
|
+
|
|
39
|
+
# Where a drain sent the calls, and what became of the tools running then.
|
|
40
|
+
Drained = Data.define(:handed, :parked, :tools, :finished)
|
|
41
|
+
|
|
42
|
+
attr_reader :url, :api_key, :env
|
|
43
|
+
|
|
44
|
+
def initialize(url:, api_key:, env: nil, ping_every: Connection::PINGS_EVERY_S, backoff: {})
|
|
45
|
+
raise ArgumentError, "Pinecall::Client.new(url:, api_key:): one of the two was not given" if url.nil? || api_key.nil?
|
|
46
|
+
|
|
47
|
+
@url = url
|
|
48
|
+
@api_key = api_key
|
|
49
|
+
@env = env
|
|
50
|
+
@agents = {}
|
|
51
|
+
@errors = []
|
|
52
|
+
@entries = []
|
|
53
|
+
@stops = []
|
|
54
|
+
@connects = []
|
|
55
|
+
@listeners = Listeners.new { |error| on_error(error) }
|
|
56
|
+
@connection = Connection.new(
|
|
57
|
+
url:, api_key:, env:, ping_every:, backoff:,
|
|
58
|
+
handlers: Connection::Handlers.new(
|
|
59
|
+
on_open: -> { opened },
|
|
60
|
+
on_entry: ->(entry) { took(entry) },
|
|
61
|
+
on_error: ->(error) { on_error(error) },
|
|
62
|
+
on_heartbeat: -> { @agents.each_value { |agent| guarded { agent.ping } } }
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Declare an agent. Nothing is sent until `connect`.
|
|
68
|
+
def agent(slug, **options)
|
|
69
|
+
@agents[slug] = Agent.new(slug, options, self)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Declared agents by slug.
|
|
73
|
+
def agents = @agents.dup
|
|
74
|
+
|
|
75
|
+
# Open the socket and register every agent.
|
|
76
|
+
def connect
|
|
77
|
+
@connection.start
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Close the socket without reconnecting; agents are unregistered.
|
|
82
|
+
def close = @connection.close
|
|
83
|
+
|
|
84
|
+
# Leave without cutting calls: each agent drains (its live calls go to another holder, or wait
|
|
85
|
+
# for the next one) and the tools running get up to `tools_s` to answer. Close afterwards.
|
|
86
|
+
def drain(answer_s: Agent::ANSWERS_WITHIN_S, tools_s: TOOLS_FINISH_WITHIN_S)
|
|
87
|
+
@connection.leaving!
|
|
88
|
+
return Drained.new(handed: 0, parked: 0, tools: 0, finished: 0) unless connected?
|
|
89
|
+
|
|
90
|
+
answers = @agents.values.filter_map { |agent| guarded { drained(agent, answer_s) } }
|
|
91
|
+
running = @agents.values.sum(&:in_flight)
|
|
92
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + tools_s
|
|
93
|
+
@agents.each_value { |agent| agent.settled(within_s: [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max) }
|
|
94
|
+
Drained.new(handed: answers.sum { |one| one[:handed] }, parked: answers.sum { |one| one[:parked] },
|
|
95
|
+
tools: running, finished: running - @agents.values.sum(&:in_flight))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Every entry the socket receives, as the gateway wrote it, before any agent takes it — other
|
|
99
|
+
# agents' entries and the client's own errors included. Returns a lambda that unsubscribes.
|
|
100
|
+
def on_entries(&listener)
|
|
101
|
+
@entries << listener
|
|
102
|
+
-> { @entries.delete(listener) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Called each time the socket is up and every agent on it registered: the first connect and every
|
|
106
|
+
# reconnect after a drop. Returns a lambda that unsubscribes.
|
|
107
|
+
def on_connected(&listener)
|
|
108
|
+
@connects << listener
|
|
109
|
+
-> { @connects.delete(listener) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Called when a member of the org stops this app: the socket is closed for good. Returns a
|
|
113
|
+
# lambda that unsubscribes.
|
|
114
|
+
def on_stopped(&listener)
|
|
115
|
+
@stops << listener
|
|
116
|
+
-> { @stops.delete(listener) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# True while the socket is up.
|
|
120
|
+
def connected? = @connection.open?
|
|
121
|
+
|
|
122
|
+
# Listen for one event type across all agents.
|
|
123
|
+
def on(type, &listener) = @listeners.on(type, &listener)
|
|
124
|
+
|
|
125
|
+
# Listen for every event across all agents.
|
|
126
|
+
def on_any(&listener) = @listeners.on_any(&listener)
|
|
127
|
+
|
|
128
|
+
# Listen for unhandled errors (bad frames, raising tools, socket loss). Without a listener
|
|
129
|
+
# they go to stderr. Returns a lambda that unsubscribes.
|
|
130
|
+
def on_errors(&listener)
|
|
131
|
+
@errors << listener
|
|
132
|
+
-> { @errors.delete(listener) }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Stream a log, reduced by the wire's reducer. `target` is `{ call: "CA_1" }` or `{ agent: … }`.
|
|
136
|
+
def observe(target, **options, &block) = Observe.observe(target, url: @url, api_key: @api_key, **options, &block)
|
|
137
|
+
|
|
138
|
+
# Fetch one page of a log and its reduced state.
|
|
139
|
+
def history(target, **options) = Observe.history(target, url: @url, api_key: @api_key, **options)
|
|
140
|
+
|
|
141
|
+
# Search the agent's knowledge bases on behalf of a call this client serves; the gateway runs
|
|
142
|
+
# the search and logs it. Returns the chunks as the gateway answered them.
|
|
143
|
+
def search(call, query, k: nil)
|
|
144
|
+
input = k.nil? ? { query: } : { query:, k: }
|
|
145
|
+
answer = Rest.post(Endpoints.lookup(@url, call), { tool: "search", input: }, api_key: @api_key, env: @env)
|
|
146
|
+
answer.dig(:output, :chunks) || []
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ── used by agents ───────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
# Send one command, validated against its schema.
|
|
152
|
+
def send_command(type:, agent:, call:, data:, id: nil)
|
|
153
|
+
@connection.send_frame(Wire.command(type:, agent:, call:, data:, id:))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Forward an event to client-wide listeners.
|
|
157
|
+
def seen(event, call) = @listeners.emit(event, call)
|
|
158
|
+
|
|
159
|
+
def on_error(error)
|
|
160
|
+
return warn("pinecall: #{error.class}: #{error.message}") if @errors.empty?
|
|
161
|
+
|
|
162
|
+
@errors.each { |listener| listener.call(error) }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def took(entry)
|
|
168
|
+
@entries.each { |listener| guarded { listener.call(entry) } }
|
|
169
|
+
return stopped(entry.data[:message]) if stop?(entry)
|
|
170
|
+
|
|
171
|
+
agent = @agents[entry.agent]
|
|
172
|
+
# The socket may carry entries for agents this client did not declare.
|
|
173
|
+
return if agent.nil?
|
|
174
|
+
|
|
175
|
+
guarded { agent.take(entry) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# An `error` coded `stopped` for no agent: a member of the org pressed Stop.
|
|
179
|
+
def stop?(entry) = entry.type == "error" && entry.agent.to_s.empty? && entry.data[:code] == STOPPED
|
|
180
|
+
|
|
181
|
+
# With no stop listener the stop is reported as an error, so it is never silent.
|
|
182
|
+
# Every agent registered again, then whoever asked to know.
|
|
183
|
+
def opened
|
|
184
|
+
@agents.each_value(&:open)
|
|
185
|
+
@connects.each { |listener| guarded { listener.call } }
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def stopped(why)
|
|
189
|
+
@connection.close
|
|
190
|
+
return on_error(NotConnected.new(why)) if @stops.empty?
|
|
191
|
+
|
|
192
|
+
@stops.each { |listener| guarded { listener.call(why) } }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The socket can close between `connected?` and the ask: then nothing was left to hand over. A
|
|
196
|
+
# gateway still connected that never answered is still said.
|
|
197
|
+
def drained(agent, answer_s)
|
|
198
|
+
agent.drain(answer_s:)
|
|
199
|
+
rescue NotConnected
|
|
200
|
+
raise if connected?
|
|
201
|
+
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def guarded
|
|
206
|
+
yield
|
|
207
|
+
rescue StandardError => e
|
|
208
|
+
on_error(e)
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# Base class for every error raised by this library. Messages say what went wrong and how to
|
|
5
|
+
# fix it.
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# The gateway refused a command with an `error` entry.
|
|
10
|
+
class Refused < Error
|
|
11
|
+
# @return [Hash{Symbol=>Object}] the wire refusal: code, message and id
|
|
12
|
+
attr_reader :refusal
|
|
13
|
+
|
|
14
|
+
def initialize(refusal)
|
|
15
|
+
@refusal = refusal
|
|
16
|
+
super("#{refusal[:code]}: #{refusal[:message]}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Machine-readable refusal code.
|
|
20
|
+
def code = @refusal[:code]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# A state field was assigned outside a tool or hook.
|
|
24
|
+
class UnauthoredWrite < Error
|
|
25
|
+
def initialize(field)
|
|
26
|
+
super("state field #{field} was assigned outside a tool and outside a lifecycle hook; " \
|
|
27
|
+
"tools are the only writers of state")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A declaration the gateway would refuse, raised at load.
|
|
32
|
+
class DeclarationRefused < Error
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# A console's ask refused with a status: raised by an `on_dev` handler, answered as `refused`.
|
|
36
|
+
class DevRefused < Error
|
|
37
|
+
attr_reader :status, :detail
|
|
38
|
+
|
|
39
|
+
def initialize(status, detail)
|
|
40
|
+
@status = status
|
|
41
|
+
@detail = detail
|
|
42
|
+
super("#{status}: #{detail}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A tool failed; the message is returned to the model as the tool result.
|
|
47
|
+
class ToolFailed < Error
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The socket is not connected.
|
|
51
|
+
class NotConnected < Error
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# The console panel an agent draws beside a conversation: the TypeScript package's `@view` and
|
|
5
|
+
# its `@pinecall/agents/panels` tags. The nodes are the same JSON, drawn by the console's own
|
|
6
|
+
# components, so a tenant's panel cannot inject styles or scripts.
|
|
7
|
+
#
|
|
8
|
+
# panel "Ficha" do |who|
|
|
9
|
+
# patient = Crm.find(who.contact)
|
|
10
|
+
# panel patient.name do
|
|
11
|
+
# rows do
|
|
12
|
+
# row "Alta", patient.since
|
|
13
|
+
# row "Zona", patient.area
|
|
14
|
+
# end
|
|
15
|
+
# badge "con saldo", tone: :warn if patient.owes?
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# A panel identifies a thread, not live call state: it is drawn for ended calls too, so it fetches
|
|
20
|
+
# its data from the tenant's own systems.
|
|
21
|
+
module Panel
|
|
22
|
+
# The conversation a panel is drawn for: the agent, the other party, the thread's newest call.
|
|
23
|
+
Who = Data.define(:agent, :contact, :call)
|
|
24
|
+
|
|
25
|
+
# A class's declared panel: its title and the block that draws it.
|
|
26
|
+
Declared = Data.define(:name, :draw)
|
|
27
|
+
|
|
28
|
+
TONES = %w[neutral good warn bad].freeze
|
|
29
|
+
|
|
30
|
+
# A value as the console shows it: nil and booleans are "", a whole float is written whole.
|
|
31
|
+
def self.said(value)
|
|
32
|
+
case value
|
|
33
|
+
when nil, true, false then ""
|
|
34
|
+
when Float then value.finite? && (value % 1).zero? ? value.to_i.to_s : value.to_s
|
|
35
|
+
else value.to_s
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Draw a class's panel for one conversation: `{ name:, nodes: }`, what `view.render` answers.
|
|
40
|
+
def self.draw(klass, who)
|
|
41
|
+
declared = klass.declared_panel
|
|
42
|
+
drawing = Drawing.new(klass)
|
|
43
|
+
drawing.instance_exec(who, &declared.draw)
|
|
44
|
+
{ name: declared.name, nodes: drawing.nodes }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module Declaring
|
|
48
|
+
# Declare the class's console panel; the block draws it for a `Who`. One per class, and a
|
|
49
|
+
# subclass does not inherit it.
|
|
50
|
+
def panel(name = "View", &draw)
|
|
51
|
+
raise DeclarationRefused, "panel takes the block that draws it" if draw.nil?
|
|
52
|
+
|
|
53
|
+
standing = @pinecall_panel
|
|
54
|
+
unless standing.nil?
|
|
55
|
+
raise DeclarationRefused,
|
|
56
|
+
"#{self.name || "this class"} declares two panels (#{standing.name} and #{name}); a class draws one panel"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@pinecall_panel = Declared.new(name: name.to_s, draw:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The panel this class declares itself, or nil.
|
|
63
|
+
def declared_panel = @pinecall_panel
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# What a panel's block draws on: one method per node of the console's closed catalogue. Any
|
|
67
|
+
# other method the block calls is the agent class's, where a tenant keeps its own helpers.
|
|
68
|
+
class Drawing
|
|
69
|
+
attr_reader :nodes
|
|
70
|
+
|
|
71
|
+
def initialize(klass)
|
|
72
|
+
@klass = klass
|
|
73
|
+
@nodes = []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A titled section; several stack vertically.
|
|
77
|
+
def panel(title = nil, &) = add(tag: "panel", title: title.nil? ? nil : Panel.said(title), children: inside(&))
|
|
78
|
+
|
|
79
|
+
# A list of labelled rows.
|
|
80
|
+
def rows(&) = add(tag: "rows", children: inside(&))
|
|
81
|
+
|
|
82
|
+
# A labelled line: `row "Alta", "12 Mar 2024"`.
|
|
83
|
+
def row(label, value = nil) = add(tag: "row", label: Panel.said(label), value: Panel.said(value))
|
|
84
|
+
|
|
85
|
+
# A prominent labelled number.
|
|
86
|
+
def stat(label, value) = add(tag: "stat", label: Panel.said(label), value: Panel.said(value))
|
|
87
|
+
|
|
88
|
+
# A table. Each row is an array in column order, or a hash keyed by the columns' names.
|
|
89
|
+
def table(columns:, rows:)
|
|
90
|
+
add(tag: "table", columns: columns.map { |column| Panel.said(column) }, rows: rows.map { |row| cells(row, columns) })
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A status badge with a tone: neutral, good, warn or bad; a word it does not know is neutral.
|
|
94
|
+
def badge(text, tone: :neutral)
|
|
95
|
+
add(tag: "badge", tone: TONES.include?(tone.to_s) ? tone.to_s : "neutral", text: Panel.said(text))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A line of text; an empty one draws nothing.
|
|
99
|
+
def text(said)
|
|
100
|
+
line = Panel.said(said).strip
|
|
101
|
+
line.empty? ? nil : add(tag: "text", text: line)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def respond_to_missing?(name, include_private = false) = @klass.respond_to?(name) || super
|
|
105
|
+
|
|
106
|
+
def method_missing(name, ...)
|
|
107
|
+
return super unless @klass.respond_to?(name)
|
|
108
|
+
|
|
109
|
+
@klass.public_send(name, ...)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def add(node)
|
|
115
|
+
@nodes << node
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The nodes a nested block draws, collected apart from the ones around it.
|
|
120
|
+
def inside
|
|
121
|
+
outer = @nodes
|
|
122
|
+
@nodes = []
|
|
123
|
+
yield if block_given?
|
|
124
|
+
@nodes
|
|
125
|
+
ensure
|
|
126
|
+
@nodes = outer
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def cells(row, columns)
|
|
130
|
+
case row
|
|
131
|
+
when Array then row.map { |cell| Panel.said(cell) }
|
|
132
|
+
when Hash then columns.map { |column| Panel.said(row.fetch(column) { row[column.to_s] || row[column.to_s.to_sym] }) }
|
|
133
|
+
else [Panel.said(row)]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# Read-only view of a state snapshot for `when:` and views: `s.slots` and `s[:slots]` are
|
|
5
|
+
# equivalent, and an undeclared field raises instead of returning nil.
|
|
6
|
+
class Reading
|
|
7
|
+
# @param remembered [Array<String>] facts known about the caller, supplied by the runtime
|
|
8
|
+
def initialize(state, remembered = [])
|
|
9
|
+
@state = state
|
|
10
|
+
@remembered = remembered
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h = @state
|
|
14
|
+
|
|
15
|
+
def [](name) = @state[name.to_sym]
|
|
16
|
+
|
|
17
|
+
def key?(name) = @state.key?(name.to_sym)
|
|
18
|
+
|
|
19
|
+
def fetch(name, *rest) = @state.fetch(name.to_sym, *rest)
|
|
20
|
+
|
|
21
|
+
# Whether a remembered fact contains `text`. Views may branch on facts but never print them;
|
|
22
|
+
# facts reach the model only as tool results.
|
|
23
|
+
def remembers?(text) = @remembered.any? { |fact| fact.to_s.include?(text.to_s) }
|
|
24
|
+
|
|
25
|
+
def respond_to_missing?(name, include_private = false)
|
|
26
|
+
@state.key?(name) || super
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def method_missing(name, *args)
|
|
30
|
+
return @state[name] if args.empty? && @state.key?(name)
|
|
31
|
+
|
|
32
|
+
super
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def inspect = "#<Pinecall::Reading #{@state.inspect}>"
|
|
36
|
+
end
|
|
37
|
+
end
|