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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agent/author"
|
|
4
|
+
require_relative "agent/doc"
|
|
5
|
+
require_relative "agent/state"
|
|
6
|
+
require_relative "agent/spec"
|
|
7
|
+
require_relative "agent/tools"
|
|
8
|
+
require_relative "agent/config"
|
|
9
|
+
require_relative "agent/knowledge"
|
|
10
|
+
require_relative "agent/searching"
|
|
11
|
+
|
|
12
|
+
module Pinecall
|
|
13
|
+
# Base class for an agent: fields are state, `tool` methods are the model's tools, comments
|
|
14
|
+
# are the prompt.
|
|
15
|
+
#
|
|
16
|
+
# class ClinicaNorte < Pinecall::Agent
|
|
17
|
+
# language "es"
|
|
18
|
+
#
|
|
19
|
+
# stage :identify, :book
|
|
20
|
+
# state :patient
|
|
21
|
+
#
|
|
22
|
+
# # Busca al paciente por nombre y teléfono.
|
|
23
|
+
# tool stage: :identify, pii: %i[name phone]
|
|
24
|
+
# def find_patient(name:, phone:)
|
|
25
|
+
# self.patient = Agenda.find(name, phone)
|
|
26
|
+
# self.stage = :book if patient
|
|
27
|
+
# patient
|
|
28
|
+
# end
|
|
29
|
+
# end
|
|
30
|
+
class Agent
|
|
31
|
+
extend Config::Declaring
|
|
32
|
+
extend State::Declaring
|
|
33
|
+
extend Tools::Declaring
|
|
34
|
+
extend Prompt::Declaring
|
|
35
|
+
extend Panel::Declaring
|
|
36
|
+
include State
|
|
37
|
+
include Tools
|
|
38
|
+
|
|
39
|
+
# Agent subclasses in definition order; `pinecall prompt agent.rb` uses it to find the class.
|
|
40
|
+
def self.written = @written ||= []
|
|
41
|
+
|
|
42
|
+
def self.inherited(subclass)
|
|
43
|
+
super
|
|
44
|
+
Agent.written << subclass
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize
|
|
48
|
+
@state = {}
|
|
49
|
+
@changes = []
|
|
50
|
+
@change_listeners = []
|
|
51
|
+
@log = []
|
|
52
|
+
@log_listeners = []
|
|
53
|
+
@event_listeners = []
|
|
54
|
+
@seq = 0
|
|
55
|
+
@sealed = false
|
|
56
|
+
@call = nil
|
|
57
|
+
@last_call = nil
|
|
58
|
+
self.class.declared_state.each { |name, default| @state[name] = opening(default) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The call being served. A method, not a field, so it never reaches a view; raises outside a call.
|
|
62
|
+
def call
|
|
63
|
+
raise Error, "there is no call here: the bridge hands an agent its call when one starts" if @call.nil?
|
|
64
|
+
|
|
65
|
+
@call
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether this instance is serving a call.
|
|
69
|
+
def call? = !@call.nil?
|
|
70
|
+
|
|
71
|
+
# The knowledge bases attached to this agent: `knowledge.search("…")` inside a tool.
|
|
72
|
+
def knowledge = Knowledge.new(self)
|
|
73
|
+
|
|
74
|
+
# Speak `text` verbatim now, as a `turn.agent`.
|
|
75
|
+
def say(text, **options) = call.say(text, **options)
|
|
76
|
+
|
|
77
|
+
# Make the model speak now, guided by instructions the caller does not hear.
|
|
78
|
+
def reply(instructions, **options) = call.reply(instructions, **options)
|
|
79
|
+
|
|
80
|
+
# Append a named entry to the call's log.
|
|
81
|
+
def log(name, data = nil)
|
|
82
|
+
entry = LogEntry.new(seq: next_seq, name: name.to_s, data:, at: Time.now.to_f)
|
|
83
|
+
@log << entry
|
|
84
|
+
@log_listeners.each { |listener| listener.call(entry) }
|
|
85
|
+
entry
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Everything this agent has logged, oldest first.
|
|
89
|
+
def logged = @log.dup
|
|
90
|
+
|
|
91
|
+
# Subscribe to logged entries; the returned lambda unsubscribes.
|
|
92
|
+
def on_log(&listener)
|
|
93
|
+
@log_listeners << listener
|
|
94
|
+
-> { @log_listeners.delete(listener) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Subscribe to external events, after `on_event` has run; the returned lambda unsubscribes.
|
|
98
|
+
def on_heard(&listener)
|
|
99
|
+
@event_listeners << listener
|
|
100
|
+
-> { @event_listeners.delete(listener) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The contact's previous call, read from the store passed to `mount(last:)`.
|
|
104
|
+
def last(contact)
|
|
105
|
+
raise Error, "last(contact) needs a store: mount the agent with last: to give it one" if @last_call.nil?
|
|
106
|
+
|
|
107
|
+
@last_call.call(contact)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ── hooks: override them; state written inside one is attributed to the hook ──
|
|
111
|
+
|
|
112
|
+
# A call started.
|
|
113
|
+
def on_call(call) = nil
|
|
114
|
+
|
|
115
|
+
# The call ended.
|
|
116
|
+
def on_end(call) = nil
|
|
117
|
+
|
|
118
|
+
# A declared external event arrived.
|
|
119
|
+
def on_event(name, data, meta) = nil
|
|
120
|
+
|
|
121
|
+
# Memory was written; persist the ops wherever the application keeps them.
|
|
122
|
+
def on_memory(ops, call) = nil
|
|
123
|
+
|
|
124
|
+
# ── called by the bridge ─────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
# Run a hook with its state writes attributed to it.
|
|
127
|
+
def run_hook(hook, *args)
|
|
128
|
+
Author.with("hook:#{hook}") { public_send(hook, *args) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Set the call this instance serves; called once, at start.
|
|
132
|
+
def serving(call)
|
|
133
|
+
@call = call
|
|
134
|
+
self
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Set the store `last(contact)` reads.
|
|
138
|
+
def reads_last_from(source)
|
|
139
|
+
@last_call = source
|
|
140
|
+
self
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Notify `on_heard` listeners.
|
|
144
|
+
def notify_heard(name, data, meta)
|
|
145
|
+
@event_listeners.each { |listener| listener.call(name, data, meta) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# Initial value for a field: Procs are called and mutable defaults duped, so calls never share one.
|
|
151
|
+
def opening(default)
|
|
152
|
+
return default.call if default.is_a?(Proc)
|
|
153
|
+
|
|
154
|
+
case default
|
|
155
|
+
when Array, Hash, String then default.dup
|
|
156
|
+
else default
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# One named prompt block and its region (`static` or `dynamic`).
|
|
5
|
+
Block = Data.define(:name, :region, :text)
|
|
6
|
+
|
|
7
|
+
# One rendered prompt: blocks in send order, plus the history between the two regions.
|
|
8
|
+
Blocks = Data.define(:blocks, :history) do
|
|
9
|
+
def [](name) = blocks.find { |block| block.name == name.to_s }&.text
|
|
10
|
+
|
|
11
|
+
# Blocks before the history; cached by the provider.
|
|
12
|
+
def static = blocks.select { |block| block.region == "static" }
|
|
13
|
+
|
|
14
|
+
# Blocks after the history; replaced every turn.
|
|
15
|
+
def dynamic = blocks.select { |block| block.region == "dynamic" }
|
|
16
|
+
|
|
17
|
+
# Non-empty static blocks joined into one text.
|
|
18
|
+
def instructions = static.map(&:text).reject(&:empty?).join("\n\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Renders the prompt as named blocks in two regions.
|
|
22
|
+
#
|
|
23
|
+
# Static blocks precede the history and are cached; the view follows it and changes every turn.
|
|
24
|
+
# Do not reorder them: the region boundary is the cache boundary. Blocks hold only the
|
|
25
|
+
# operator's text; lookup results go in the history as tool results.
|
|
26
|
+
module Prompt
|
|
27
|
+
# Blocks every agent has, in send order.
|
|
28
|
+
FRAMEWORK = [
|
|
29
|
+
{ name: "identity", region: "static" }.freeze,
|
|
30
|
+
{ name: "knowledge", region: "static" }.freeze,
|
|
31
|
+
{ name: "tools", region: "static" }.freeze,
|
|
32
|
+
{ name: "view", region: "dynamic" }.freeze
|
|
33
|
+
].freeze
|
|
34
|
+
|
|
35
|
+
module Declaring
|
|
36
|
+
# Declare or read the template for the `view` block.
|
|
37
|
+
#
|
|
38
|
+
# view # views/<slug>.erb next to this file (the default)
|
|
39
|
+
# view "views/reception.erb" # relative to this file
|
|
40
|
+
# view template: <<~ERB # inline
|
|
41
|
+
#
|
|
42
|
+
# The default lookup runs once and is memoized.
|
|
43
|
+
def view(path = nil, template: nil)
|
|
44
|
+
unless path.nil? && template.nil?
|
|
45
|
+
@view = template ? View.inline(template, "#{name} (inline view)") : View.file(beside_this_file(path))
|
|
46
|
+
return @view
|
|
47
|
+
end
|
|
48
|
+
return @view if defined?(@view)
|
|
49
|
+
|
|
50
|
+
@view = view_of_this_class || (superclass.respond_to?(:view) ? superclass.view : nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def view_of_this_class
|
|
54
|
+
here = source_file
|
|
55
|
+
return nil if here.nil?
|
|
56
|
+
|
|
57
|
+
path = View.beside(here, slug)
|
|
58
|
+
File.exist?(path) ? View.file(path) : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Relative paths resolve against the class's file, not the working directory.
|
|
62
|
+
def beside_this_file(path)
|
|
63
|
+
here = source_file
|
|
64
|
+
return path if here.nil? || path.start_with?("/")
|
|
65
|
+
|
|
66
|
+
File.expand_path(path, File.dirname(here))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def source_file
|
|
70
|
+
found = name && Object.const_source_location(name)
|
|
71
|
+
found&.first
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
module_function
|
|
76
|
+
|
|
77
|
+
def render(agent, resumed: false, remembered: [], line: nil)
|
|
78
|
+
reading = reading_for(agent, resumed:, remembered:, line:)
|
|
79
|
+
blocks = FRAMEWORK.map do |spec|
|
|
80
|
+
Block.new(name: spec[:name], region: spec[:region], text: text_of(agent, spec[:name], reading))
|
|
81
|
+
end
|
|
82
|
+
Blocks.new(blocks:, history: history(agent))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The class docstring, the framework's rules and protocols, and the channel's. Reads no state.
|
|
86
|
+
def identity(agent)
|
|
87
|
+
[agent.doc, tagged("rules", Rules::RULES), tagged("protocols", Rules::PROTOCOLS), channel(agent)]
|
|
88
|
+
.compact.reject { |part| part.strip.empty? }.join("\n\n")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Static because a call never changes its channel or medium. With no call (an offline prompt), a
|
|
92
|
+
# phone call's.
|
|
93
|
+
def channel(agent)
|
|
94
|
+
return nil if agent.class.channel_rules == false
|
|
95
|
+
|
|
96
|
+
call = agent.call? ? agent.call : nil
|
|
97
|
+
tagged("channel", Rules.channel_rules_for(call&.channel || "phone", call&.medium || "voice"))
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Every declared tool's docstring, visible or not; schemas travel separately on the wire.
|
|
101
|
+
def tools(agent)
|
|
102
|
+
docs = agent.tools.map { |spec| "- #{spec[:name]}: #{spec[:description]}" }.join("\n")
|
|
103
|
+
tagged("tools", docs)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The runtime owns the turns; the framework adds only the summaries left by `collapse`.
|
|
107
|
+
def history(agent)
|
|
108
|
+
agent.changes.select { |change| change.field == "@summary" }
|
|
109
|
+
.map { |change| "#{collapsed(change.seq)}\n#{change.next}" }
|
|
110
|
+
.join("\n\n")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Marker for a collapsed stretch of the call.
|
|
114
|
+
def collapsed(seq) = "<!-- collapsed: #{JSON.generate({ seq: })} -->"
|
|
115
|
+
|
|
116
|
+
def view(agent, reading)
|
|
117
|
+
view = agent.class.view
|
|
118
|
+
view.nil? ? "" : view.render(reading)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The view's scope: state, call context, and remembered facts (queryable, never printed).
|
|
122
|
+
def reading_for(agent, resumed: false, remembered: [], line: nil)
|
|
123
|
+
Reading.new(agent.snapshot.merge(resumed:, call: line || { channel: "web" }), remembered)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# What `Pinecall::Serve`'s `prompt` prints: every block under its header.
|
|
127
|
+
def header_for(section, region = nil) = "── #{[section, region && "(#{region})"].compact.join(" ")} ──"
|
|
128
|
+
|
|
129
|
+
# The prompt as one page, as `pinecall prompt` prints it.
|
|
130
|
+
def show(agent, **context)
|
|
131
|
+
rendered = render(agent, **context)
|
|
132
|
+
sections = rendered.static.map { |block| [header_for(block.name, block.region), block.text] }
|
|
133
|
+
sections << [header_for("history"), rendered.history]
|
|
134
|
+
sections += rendered.dynamic.map { |block| [header_for(block.name, block.region), block.text] }
|
|
135
|
+
sections.map { |header, text| "#{header}\n#{text}".rstrip }.join("\n\n")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def text_of(agent, called, reading)
|
|
139
|
+
case called
|
|
140
|
+
when "identity" then identity(agent)
|
|
141
|
+
# Filled by the gateway from the agent's settings (`pinecall agent knowledge edit`).
|
|
142
|
+
when "knowledge" then ""
|
|
143
|
+
when "tools" then tools(agent)
|
|
144
|
+
when "view" then view(agent, reading)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def tagged(name, body)
|
|
149
|
+
body.to_s.empty? ? "" : "<#{name}>\n#{body}\n</#{name}>"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# Mounts an agent class on a client, with one instance per call. The only module that knows
|
|
5
|
+
# both the agent and the socket.
|
|
6
|
+
module Bridge
|
|
7
|
+
# A mounted agent and its live instances.
|
|
8
|
+
class Mounted
|
|
9
|
+
attr_reader :slug, :agent, :options
|
|
10
|
+
|
|
11
|
+
def initialize(slug:, agent:, options:, live:)
|
|
12
|
+
@slug = slug
|
|
13
|
+
@agent = agent
|
|
14
|
+
@options = options
|
|
15
|
+
@live = live
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The instance serving `call_id`, or nil.
|
|
19
|
+
def serving(call_id) = @live[call_id]&.agent
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# One served call: its instance, what was last sent, and a thread running its jobs in order.
|
|
23
|
+
class Live
|
|
24
|
+
attr_reader :agent, :world, :sent, :stops
|
|
25
|
+
# What memory recalled about this caller in this call: what `remembers?` answers from.
|
|
26
|
+
attr_accessor :tools_shown, :remembered
|
|
27
|
+
|
|
28
|
+
def initialize(agent, world)
|
|
29
|
+
@agent = agent
|
|
30
|
+
@world = world
|
|
31
|
+
@sent = {}
|
|
32
|
+
@tools_shown = nil
|
|
33
|
+
@remembered = []
|
|
34
|
+
@stops = {}
|
|
35
|
+
@work = Thread::Queue.new
|
|
36
|
+
@thread = Thread.new { work }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Queue a job; jobs run one at a time in order, which keeps `world.cause` accurate.
|
|
40
|
+
def later(&job) = @work << job
|
|
41
|
+
|
|
42
|
+
def finish
|
|
43
|
+
@work << :done
|
|
44
|
+
@thread.join(5)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def work
|
|
50
|
+
while (job = @work.pop)
|
|
51
|
+
break if job == :done
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
job.call
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
warn("pinecall: #{e.class}: #{e.message}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# Mount a class on a client; each call gets its own instance. Nothing is sent until `connect`.
|
|
65
|
+
#
|
|
66
|
+
# @param last [Proc] source for `last(contact)`
|
|
67
|
+
# @param takes_unclaimed [Boolean] false for a console, which serves only calls it opens
|
|
68
|
+
def mount(klass, client:, slug: nil, last: nil, takes_unclaimed: true)
|
|
69
|
+
name = slug || klass.slug
|
|
70
|
+
live = {}
|
|
71
|
+
# Throwaway instance used only to read the class's declarations.
|
|
72
|
+
probe = klass.new
|
|
73
|
+
options = klass.wire_config(tools: tools_for(probe, live)).merge(takes_unclaimed:)
|
|
74
|
+
agent = client.agent(name, **options)
|
|
75
|
+
agent.on("call.started") { |started, call| start(klass, live, call, client, last, started[:state]) }
|
|
76
|
+
agent.on("call.attached") { |_data, call| attached(klass, live, call, client, last) }
|
|
77
|
+
agent.on("call.ended") { |_data, call| finish(live, call) }
|
|
78
|
+
Mounted.new(slug: name, agent:, options:, live:)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Tool specs with a `run` routed to the instance serving the calling call.
|
|
82
|
+
def tools_for(probe, live)
|
|
83
|
+
probe.tools.map do |spec|
|
|
84
|
+
spec.merge(run: lambda do |arguments, call|
|
|
85
|
+
serving = live[call.id]
|
|
86
|
+
raise ToolFailed, "#{spec[:name]}: this call is no longer being served" if serving.nil?
|
|
87
|
+
|
|
88
|
+
serving.agent.run_tool(spec[:name], arguments)
|
|
89
|
+
end)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A call handed to this process mid-conversation: no `on_call`, the state the gateway kept
|
|
94
|
+
# instead. One served here already (the gateway restarted) keeps its instance, and its whole
|
|
95
|
+
# prompt is sent again, which the gateway may have lost.
|
|
96
|
+
def attached(klass, live, call, client, last)
|
|
97
|
+
held = live[call.id]
|
|
98
|
+
return held.later { resend(held, call) } unless held.nil?
|
|
99
|
+
|
|
100
|
+
instance = klass.new.seal
|
|
101
|
+
instance.reads_last_from(last) unless last.nil?
|
|
102
|
+
serving = Live.new(instance, world_for(call, client))
|
|
103
|
+
instance.serving(serving.world)
|
|
104
|
+
live[call.id] = serving
|
|
105
|
+
serving.later do
|
|
106
|
+
instance.restore(call.state)
|
|
107
|
+
sync(serving, call)
|
|
108
|
+
listen(serving, call)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resend(serving, call)
|
|
113
|
+
serving.sent.clear
|
|
114
|
+
serving.tools_shown = nil
|
|
115
|
+
sync(serving, call)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def world_for(call, client)
|
|
119
|
+
searching = ->(query, k) { client.search(call.id, query, k:) }
|
|
120
|
+
CallWorld.new(id: call.id, contact: call.contact&.dig(:id) || call.from, from: call.from, channel: call.channel,
|
|
121
|
+
medium: call.medium, today: call.today, claimed: call.claimed, searching:) do |type, data|
|
|
122
|
+
call.command(type, data)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Build the instance and run `on_call` before listening, so the first prompt is sent once
|
|
127
|
+
# rather than once per field. The state the opener asked for (`call.started.state`) is applied
|
|
128
|
+
# after the hook, which would overwrite it, and before the first render.
|
|
129
|
+
def start(klass, live, call, client, last, opened_in)
|
|
130
|
+
instance = klass.new.seal
|
|
131
|
+
instance.reads_last_from(last) unless last.nil?
|
|
132
|
+
world = world_for(call, client)
|
|
133
|
+
instance.serving(world)
|
|
134
|
+
serving = Live.new(instance, world)
|
|
135
|
+
live[call.id] = serving
|
|
136
|
+
serving.later do
|
|
137
|
+
instance.run_hook(:on_call, world)
|
|
138
|
+
instance.start_in(opened_in) unless opened_in.nil?
|
|
139
|
+
call.set_state(instance.snapshot)
|
|
140
|
+
sync(serving, call)
|
|
141
|
+
listen(serving, call)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Re-sync the prompt on every state change.
|
|
146
|
+
def listen(serving, call)
|
|
147
|
+
instance = serving.agent
|
|
148
|
+
serving.stops[:state] = instance.on_change do |change|
|
|
149
|
+
call.set_state(instance.snapshot, [change.field])
|
|
150
|
+
# `state.set` has no cause field on the wire, so log it separately.
|
|
151
|
+
cause = serving.world.cause
|
|
152
|
+
call.log("state.cause", { field: change.field.to_s, kind: "event" }.merge(cause)) unless cause.nil?
|
|
153
|
+
sync(serving, call)
|
|
154
|
+
end
|
|
155
|
+
serving.stops[:log] = instance.on_log { |entry| call.log(entry.name, as_object(entry.data)) }
|
|
156
|
+
serving.stops[:entries] = call.on_any do |event|
|
|
157
|
+
serving.world.take(event.type, event.data, Time.now.to_f)
|
|
158
|
+
received(serving, event.data) if event.type == "event.received"
|
|
159
|
+
rerendered(serving, call, event)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The view answers the turn being taken, not the one before it; a claim lets the caller see the
|
|
164
|
+
# page; recall is not state, so nothing else would render what it brought.
|
|
165
|
+
def rerendered(serving, call, event)
|
|
166
|
+
case event.type
|
|
167
|
+
when "turn.user", "call.claimed" then sync(serving, call)
|
|
168
|
+
when "memory.ops"
|
|
169
|
+
serving.remembered = words_recalled(event.data)
|
|
170
|
+
sync(serving, call)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Each recalled fact's text and category; a `remember` op stores this call's facts, it surfaces none.
|
|
175
|
+
def words_recalled(data)
|
|
176
|
+
(data[:ops] || []).select { |op| op[:op] == "recall" }.flat_map do |op|
|
|
177
|
+
(op[:facts] || []).flat_map { |fact| [fact[:text], fact[:category] || ""] }
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Drop events whose (name, source) the class did not declare: an `:app` event arriving from
|
|
182
|
+
# a browser could be spoofed.
|
|
183
|
+
def received(serving, fact)
|
|
184
|
+
name = fact[:name].to_s
|
|
185
|
+
allowed = agent_class(serving).declared_events[name]
|
|
186
|
+
unless allowed&.include?(fact[:source].to_s)
|
|
187
|
+
return warn("pinecall: #{name} from #{fact[:source]} is not declared by this agent")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
meta = { source: fact[:source], identity: fact[:identity], seq: serving.world.numbered }.compact
|
|
191
|
+
serving.later do
|
|
192
|
+
serving.world.cause = { event: name, event_seq: meta[:seq] }
|
|
193
|
+
serving.agent.run_hook(:on_event, name, fact[:data] || {}, meta)
|
|
194
|
+
serving.agent.notify_heard(name, fact[:data] || {}, meta)
|
|
195
|
+
serving.world.cause = nil
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Stop rendering and reading entries; the log stays open until `on_end` has run.
|
|
200
|
+
def finish(live, call)
|
|
201
|
+
serving = live.delete(call.id)
|
|
202
|
+
return if serving.nil?
|
|
203
|
+
|
|
204
|
+
serving.stops.values_at(:state, :entries).compact.each(&:call)
|
|
205
|
+
serving.later do
|
|
206
|
+
serving.agent.run_hook(:on_end, serving.world)
|
|
207
|
+
serving.stops[:log]&.call
|
|
208
|
+
end
|
|
209
|
+
serving.finish
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Send only blocks whose text changed since this call's last send: identical re-sends waste
|
|
213
|
+
# the provider's prompt cache. Unsent blocks count as empty.
|
|
214
|
+
def sync(serving, call)
|
|
215
|
+
line = { channel: call.channel || "web", from: call.from, claimed: serving.world.claimed }
|
|
216
|
+
rendered = Prompt.render(serving.agent, remembered: serving.remembered, line:)
|
|
217
|
+
rendered.blocks.each do |block|
|
|
218
|
+
next if block.text == serving.sent.fetch(block.name, "")
|
|
219
|
+
|
|
220
|
+
serving.sent[block.name] = block.text
|
|
221
|
+
call.set_prompt(block.name, block.text)
|
|
222
|
+
end
|
|
223
|
+
visible = serving.agent.visible_tools
|
|
224
|
+
return if visible == serving.tools_shown
|
|
225
|
+
|
|
226
|
+
serving.tools_shown = visible
|
|
227
|
+
call.set_tools(visible)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def agent_class(serving) = serving.agent.class
|
|
231
|
+
|
|
232
|
+
# `call.log` data must be an object; wrap scalars under `value`.
|
|
233
|
+
def as_object(data)
|
|
234
|
+
return {} if data.nil?
|
|
235
|
+
|
|
236
|
+
data.is_a?(Hash) ? data : { value: data }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What a verb of the call waits for, and the answers it gets: a transfer, a supervisor, a search.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
class CallWorld
|
|
7
|
+
# Seconds `say`/`reply` wait for their turn before returning false.
|
|
8
|
+
LANDS_WITHIN_S = 30
|
|
9
|
+
# A transfer's far end may ring for 25 s before it fails.
|
|
10
|
+
TRANSFER_WITHIN_S = 90
|
|
11
|
+
# What `attention` waits beyond `wait_s` for the answer entry to arrive.
|
|
12
|
+
A_MOMENT_S = 15
|
|
13
|
+
# No answer came back: the gateway, not the far end, went quiet.
|
|
14
|
+
NO_ANSWER = "the runtime never said how it went"
|
|
15
|
+
# A pending verb when the call ends first.
|
|
16
|
+
THE_CALL_ENDED = "the call ended before it was answered"
|
|
17
|
+
# `search` on a call no gateway serves (an offline prompt, a test).
|
|
18
|
+
NO_GATEWAY_TO_SEARCH = "this call cannot search: no gateway is serving it"
|
|
19
|
+
|
|
20
|
+
# How a transfer went. `ok: false` means the caller is still with the agent.
|
|
21
|
+
Transferred = Data.define(:to, :mode, :ok, :error)
|
|
22
|
+
|
|
23
|
+
# Who took the line, or why nobody did.
|
|
24
|
+
Attended = Data.define(:ok, :by, :error)
|
|
25
|
+
|
|
26
|
+
# One search hit: its source path, heading and text.
|
|
27
|
+
Found = Data.define(:path, :heading, :text)
|
|
28
|
+
|
|
29
|
+
# The verbs waiting for an entry, by kind: `:spoken`, `:transfers`, `:asks`. The runtime always
|
|
30
|
+
# answers with an entry; the ceiling only guards against a gateway that went away.
|
|
31
|
+
class Waiting
|
|
32
|
+
def initialize
|
|
33
|
+
@waiting = Hash.new { |kinds, kind| kinds[kind] = [] }
|
|
34
|
+
@lock = Mutex.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Block until the answer of this kind arrives, or return `lapsed` after `ceiling_s`.
|
|
38
|
+
def wait(kind, ceiling_s, lapsed)
|
|
39
|
+
answer = Thread::Queue.new
|
|
40
|
+
@lock.synchronize { @waiting[kind] << answer }
|
|
41
|
+
yield
|
|
42
|
+
got = answer.pop(timeout: ceiling_s)
|
|
43
|
+
got.nil? ? lapsed : got
|
|
44
|
+
ensure
|
|
45
|
+
@lock.synchronize { @waiting[kind].delete(answer) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Answer every verb waiting for this kind.
|
|
49
|
+
def settle(kind, answer)
|
|
50
|
+
@lock.synchronize { @waiting.delete(kind) || [] }.each { |waiting| waiting.push(answer) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The call ended: every verb waiting is answered now instead of at its ceiling.
|
|
54
|
+
def end_all
|
|
55
|
+
settle(:spoken, false)
|
|
56
|
+
settle(:transfers, Transferred.new(to: "", mode: nil, ok: false, error: THE_CALL_ENDED))
|
|
57
|
+
settle(:asks, Attended.new(ok: false, by: nil, error: THE_CALL_ENDED))
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Who is in the call's room and what was said: the participants, a seat's two verbs, the turns.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
# A room participant, as reported by the room's entries.
|
|
7
|
+
Participant = Struct.new(:identity, :kind, :name, :joined_at, :speaking, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
# A finished conversation turn.
|
|
10
|
+
Turn = Data.define(:who, :text, :speech_id, :interrupted, :at)
|
|
11
|
+
|
|
12
|
+
class CallWorld
|
|
13
|
+
# A participant handle. Removing the caller ends the call.
|
|
14
|
+
class Seat
|
|
15
|
+
def initialize(identity, send)
|
|
16
|
+
@identity = identity
|
|
17
|
+
@send = send
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def mute(muted: true) = @send.call("participant.mute", { identity: @identity, muted: })
|
|
21
|
+
|
|
22
|
+
def remove = @send.call("participant.remove", { identity: @identity })
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|