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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
# The framework's own words in the identity block: the rules, the protocols, and the channel's.
|
|
5
|
+
# English for every agent: a model follows English instructions and answers in any language.
|
|
6
|
+
# Constant within a call, so they stay in the cached prompt prefix. Word for word the TypeScript
|
|
7
|
+
# package's `built-in-rules.ts`.
|
|
8
|
+
module Rules
|
|
9
|
+
RULES = [
|
|
10
|
+
"- Invent nothing: if it did not come from a tool or from the knowledge, do not say it.",
|
|
11
|
+
"- One question per turn, and wait for the answer.",
|
|
12
|
+
"- Answer in the language the caller speaks."
|
|
13
|
+
].join("\n").freeze
|
|
14
|
+
|
|
15
|
+
PROTOCOLS = [
|
|
16
|
+
"- To act, call a tool; saying you have done something does not do it.",
|
|
17
|
+
"- Before an irreversible action read back what you are about to do and wait for an explicit yes.",
|
|
18
|
+
"- If you cannot solve it, say so and offer to hand over to a person."
|
|
19
|
+
].join("\n").freeze
|
|
20
|
+
|
|
21
|
+
SPOKEN = "You are on a phone call. Everything you write is read aloud by a voice: short spoken sentences, " \
|
|
22
|
+
"no lists, no bold, no symbols, no links. Say an email or a web address the way a person says it out loud."
|
|
23
|
+
|
|
24
|
+
ON_A_WEBSITE = "You are in a written chat on a website. Markdown is fine: short paragraphs, a list when there " \
|
|
25
|
+
"are steps, bold for the one thing that matters."
|
|
26
|
+
|
|
27
|
+
ON_WHATSAPP = "You are on WhatsApp. Use its formatting: *bold*, _italic_, no headings, no tables, short messages."
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# How to write on this channel and medium: WhatsApp's formatting, a website's Markdown, or speech.
|
|
32
|
+
def channel_rules_for(channel, medium)
|
|
33
|
+
return ON_WHATSAPP if channel.to_s == "whatsapp"
|
|
34
|
+
|
|
35
|
+
channel.to_s == "web" && medium.to_s == "text" ? ON_A_WEBSITE : SPOKEN
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The agents a process holds, and leaving: a drain, then the socket closed, once.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Serve
|
|
7
|
+
# What `Pinecall.serve` and `serve start` hold: the client, the agents mounted on it.
|
|
8
|
+
class Held
|
|
9
|
+
attr_reader :client, :mounted
|
|
10
|
+
|
|
11
|
+
def initialize(client, mounted)
|
|
12
|
+
@client = client
|
|
13
|
+
@mounted = mounted
|
|
14
|
+
@stopped = Thread::Queue.new
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
@drained = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Drain every agent, then close the socket; asked twice, the first answer. Returns the drain.
|
|
20
|
+
def stop
|
|
21
|
+
@lock.synchronize do
|
|
22
|
+
return @drained unless @drained.nil?
|
|
23
|
+
|
|
24
|
+
@drained = @client.drain
|
|
25
|
+
close
|
|
26
|
+
@drained
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Close without draining: a second signal, or a stop the gateway already made.
|
|
31
|
+
def close
|
|
32
|
+
@client.close
|
|
33
|
+
@stopped.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Block until the socket is closed.
|
|
37
|
+
def join = @stopped.pop
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What the serve entry is told and loads: its flags, the class in a file, and a state field by field.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
module Serve
|
|
7
|
+
# Thrown when what was asked cannot be served at all; the entry exits 2 with its sentence.
|
|
8
|
+
class CannotServe < Error; end
|
|
9
|
+
|
|
10
|
+
# One agent file and the slug it is served as.
|
|
11
|
+
Served = Data.define(:file, :slug)
|
|
12
|
+
|
|
13
|
+
# The flags of one verb, as the CLI passes them.
|
|
14
|
+
Flags = Data.define(:served, :console, :events, :prod, :state, :channel, :medium, :show_machine)
|
|
15
|
+
|
|
16
|
+
module Loading
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# `--file` and `--slug` paired by place; the rest by name. A flag nobody declared is refused.
|
|
20
|
+
def parse(argv)
|
|
21
|
+
found = { files: [], slugs: [], console: false, events: false, prod: false, state: {}, channel: "phone", medium: nil,
|
|
22
|
+
show_machine: false }
|
|
23
|
+
words = argv.dup
|
|
24
|
+
until words.empty?
|
|
25
|
+
word = words.shift
|
|
26
|
+
case word
|
|
27
|
+
when "--file" then found[:files] << value_of(word, words)
|
|
28
|
+
when "--slug" then found[:slugs] << value_of(word, words)
|
|
29
|
+
when "--state" then found[:state].merge!(as_state(value_of(word, words)))
|
|
30
|
+
when "--channel" then found[:channel] = value_of(word, words)
|
|
31
|
+
when "--console", "--events", "--prod" then found[word.delete_prefix("--").to_sym] = true
|
|
32
|
+
when "--show-machine" then found[:show_machine] = true
|
|
33
|
+
when "--medium" then found[:medium] = value_of(word, words)
|
|
34
|
+
else raise CannotServe, "serve has no flag #{word}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
flags(found)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# `field=json`: the value is the field's, as JSON; a value that is not JSON is refused by its field.
|
|
41
|
+
def as_state(pair)
|
|
42
|
+
field, equals, value = pair.to_s.partition("=")
|
|
43
|
+
raise CannotServe, "--state #{pair}: a field, =, and its value as JSON" if field.empty? || equals.empty?
|
|
44
|
+
|
|
45
|
+
{ field.to_sym => JSON.parse(value, symbolize_names: true) }
|
|
46
|
+
rescue JSON::ParserError
|
|
47
|
+
raise CannotServe, "--state #{field}: its value is not JSON"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The class the file defines, refused when its own `slug` says another than the one it is served as.
|
|
51
|
+
def load_served(served)
|
|
52
|
+
klass = load_agent(served.file)
|
|
53
|
+
declared = klass.declared_slug
|
|
54
|
+
if !declared.nil? && declared.to_s != served.slug
|
|
55
|
+
raise CannotServe, "#{served.file} says its slug is #{declared}, and it is served as #{served.slug}: the slug is its folder's name"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
klass
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The last `Pinecall::Agent` the file defines.
|
|
62
|
+
def load_agent(file)
|
|
63
|
+
path = File.expand_path(file)
|
|
64
|
+
raise CannotServe, "no agent at #{path}" unless File.exist?(path)
|
|
65
|
+
|
|
66
|
+
before = Agent.written.dup
|
|
67
|
+
begin
|
|
68
|
+
loaded = require path
|
|
69
|
+
rescue ScriptError, LoadError => e
|
|
70
|
+
raise CannotServe, "#{path} did not load: #{e.message}"
|
|
71
|
+
end
|
|
72
|
+
# `require` answers false for a file already loaded: its class is found by its source.
|
|
73
|
+
written = loaded ? Agent.written - before : Agent.written.select { |klass| klass.source_file == path }
|
|
74
|
+
written = written.select(&:name)
|
|
75
|
+
raise CannotServe, "#{path} declares no Pinecall::Agent" if written.empty?
|
|
76
|
+
|
|
77
|
+
written.last
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def value_of(flag, words)
|
|
81
|
+
value = words.shift
|
|
82
|
+
raise CannotServe, "#{flag} takes a value" if value.nil? || value.start_with?("--")
|
|
83
|
+
|
|
84
|
+
value
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flags(found)
|
|
88
|
+
files, slugs = found.values_at(:files, :slugs)
|
|
89
|
+
if files.empty? || files.length != slugs.length
|
|
90
|
+
raise CannotServe, "serve takes one --slug for each --file, and at least one of each"
|
|
91
|
+
end
|
|
92
|
+
unless Wire::Enums::CHANNEL.include?(found[:channel]) && [nil, *Wire::Enums::MEDIUM].include?(found[:medium])
|
|
93
|
+
raise CannotServe, "--channel is phone, web or whatsapp, and --medium is voice or text"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Flags.new(served: files.zip(slugs).map { |file, slug| Served.new(file:, slug:) },
|
|
97
|
+
console: found[:console], events: found[:events], prod: found[:prod], state: found[:state],
|
|
98
|
+
channel: found[:channel], medium: found[:medium], show_machine: found[:show_machine])
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
module Serve
|
|
5
|
+
# The one console verb an agent's own process answers: its class's panel, for a conversation.
|
|
6
|
+
# Every other verb is the CLI's companion's. Word for word the TypeScript serve entry's answers.
|
|
7
|
+
module Viewing
|
|
8
|
+
ONLY_THE_VIEW = "this process answers only view.render: the console's other verbs are the CLI's"
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The class's panel for the conversation asked, or a refusal with its status.
|
|
13
|
+
def answer(klass, slug, verb, asked)
|
|
14
|
+
raise DevRefused.new(404, ONLY_THE_VIEW) unless verb == "view.render"
|
|
15
|
+
|
|
16
|
+
who = a_conversation(slug, asked)
|
|
17
|
+
raise DevRefused.new(404, "#{slug} declares no view: nothing in this directory draws a panel") if klass.declared_panel.nil?
|
|
18
|
+
|
|
19
|
+
begin
|
|
20
|
+
Panel.draw(klass, who)
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
# The pane says it, and the conversation's screen keeps working.
|
|
23
|
+
raise DevRefused.new(502, "#{slug}'s view failed: #{e.message}")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def a_conversation(slug, asked)
|
|
28
|
+
raise DevRefused.new(422, "a panel is asked for with a JSON object") unless asked.is_a?(Hash)
|
|
29
|
+
|
|
30
|
+
named = %i[contact call].to_h do |name|
|
|
31
|
+
value = asked[name]
|
|
32
|
+
raise DevRefused.new(422, "#{name} is a name, and it was missing") unless value.is_a?(String) && !value.empty?
|
|
33
|
+
|
|
34
|
+
[name, value]
|
|
35
|
+
end
|
|
36
|
+
Panel::Who.new(agent: slug, **named)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "serve/loading"
|
|
4
|
+
require_relative "serve/held"
|
|
5
|
+
require_relative "serve/viewing"
|
|
6
|
+
|
|
7
|
+
module Pinecall
|
|
8
|
+
# The entry the one `pinecall` CLI starts a Ruby agent with: `start` holds the agents and serves
|
|
9
|
+
# their calls, `prompt` prints a prompt. The CLI runs it as
|
|
10
|
+
# `ruby -r pinecall -e 'exit Pinecall::Serve.main(ARGV)' -- start --file … --slug …`.
|
|
11
|
+
#
|
|
12
|
+
# It reads its door from `PINECALL_URL`, `PINECALL_KEY` and `PINECALL_ENV` alone, and leaves on
|
|
13
|
+
# SIGINT, SIGTERM or the end of its stdin, draining first; a second signal leaves now.
|
|
14
|
+
module Serve
|
|
15
|
+
USAGE = <<~USAGE
|
|
16
|
+
usage: serve start --file <agent.rb> --slug <slug> [--file … --slug …] [--console] [--events] [--prod]
|
|
17
|
+
serve prompt --file <agent.rb> --slug <slug> [--state field=json]… [--channel name] [--show-machine]
|
|
18
|
+
USAGE
|
|
19
|
+
|
|
20
|
+
NO_DOOR = "serve start reads PINECALL_URL and PINECALL_KEY from its environment, and one was not set"
|
|
21
|
+
|
|
22
|
+
WORLDS = %w[sandbox production].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Run one verb and answer its exit status: 2 when it cannot run at all, with the sentence on `err`.
|
|
27
|
+
def main(argv, out: $stdout, err: $stderr, env: ENV, input: $stdin, signals: nil)
|
|
28
|
+
verb, *rest = argv
|
|
29
|
+
case verb
|
|
30
|
+
when "start" then start(Loading.parse(rest), out:, err:, env:, input:, signals:)
|
|
31
|
+
when "prompt" then prompt(Loading.parse(rest), out:)
|
|
32
|
+
else err.puts(USAGE) || 2
|
|
33
|
+
end
|
|
34
|
+
rescue CannotServe, NotConnected => e
|
|
35
|
+
err.puts(e.message)
|
|
36
|
+
2
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Hold every agent named until asked to leave; 0 once they drained.
|
|
40
|
+
def start(flags, out:, err:, env:, input:, signals:)
|
|
41
|
+
client = client_from(env, prod: flags.prod)
|
|
42
|
+
classes = flags.served.map { |served| [served, Loading.load_served(served)] }
|
|
43
|
+
said(client, out:, err:, events: flags.events)
|
|
44
|
+
mounted = classes.map do |served, klass|
|
|
45
|
+
held = Pinecall.mount(klass, client:, slug: served.slug, takes_unclaimed: !flags.console)
|
|
46
|
+
held.agent.on_dev { |verb, data| Viewing.answer(klass, served.slug, verb, data) }
|
|
47
|
+
held
|
|
48
|
+
end
|
|
49
|
+
held = Held.new(client, mounted)
|
|
50
|
+
asked = signals || trapped
|
|
51
|
+
client.on_stopped { |why| err.puts(why) || asked.push(:stopped) }
|
|
52
|
+
client.connect
|
|
53
|
+
Thread.new { ended(input, asked) }
|
|
54
|
+
leave(held, asked, err)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Load the class, open it in the state the pairs name, and print its prompt. No gateway, no key.
|
|
58
|
+
def prompt(flags, out:)
|
|
59
|
+
klass = Loading.load_served(flags.served.first)
|
|
60
|
+
instance = klass.new.seal
|
|
61
|
+
# A call of its own, so the channel's block is the one this channel and medium get.
|
|
62
|
+
instance.serving(CallWorld.new(id: "", contact: "", channel: flags.channel, medium: flags.medium) { |*| nil })
|
|
63
|
+
instance.start_in(flags.state) unless flags.state.empty?
|
|
64
|
+
out.puts(Pinecall.show_prompt(instance, line: { channel: flags.channel }))
|
|
65
|
+
out.puts("\n#{machine(instance)}") if flags.show_machine
|
|
66
|
+
0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The tools the class declares, marking the ones this state shows, under the stage.
|
|
70
|
+
def machine(instance)
|
|
71
|
+
shown = instance.visible_tools.map { |spec| spec[:name] }
|
|
72
|
+
stage = instance.respond_to?(:stage) ? " stage: #{instance.stage}" : ""
|
|
73
|
+
header = "#{Prompt.header_for("tools")}#{stage}"
|
|
74
|
+
return "#{header}\n\n this agent declares no tools" if instance.tools.empty?
|
|
75
|
+
|
|
76
|
+
declared = instance.class.declared_tools.values
|
|
77
|
+
width = declared.map { |one| one.name.length }.max
|
|
78
|
+
lines = declared.map do |one|
|
|
79
|
+
visible = shown.include?(one.name.to_s)
|
|
80
|
+
" #{visible ? "●" : "○"} #{one.name.to_s.ljust(width)} #{gated_by(instance, one.options, visible)}"
|
|
81
|
+
end
|
|
82
|
+
[header, "", *lines].join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# What gates a tool: its stages, its `when`, or nothing. A tool hidden in its own stage is always
|
|
86
|
+
# its `when`'s doing, and says so.
|
|
87
|
+
def gated_by(instance, options, visible)
|
|
88
|
+
stages = options[:stage] && Array(options[:stage]).map(&:to_sym)
|
|
89
|
+
return options[:when].nil? ? "always" : "when(state)" if stages.nil?
|
|
90
|
+
|
|
91
|
+
here = instance.respond_to?(:stage) ? instance.stage : nil
|
|
92
|
+
named = stages.join(" · ")
|
|
93
|
+
!visible && !here.nil? && stages.include?(here) ? "#{named} · when(state) says no" : named
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Where the calls went, and what became of the tools running: one line on stderr.
|
|
97
|
+
def drain_line(drained)
|
|
98
|
+
return "draining · no live calls" if drained.handed + drained.parked + drained.tools == 0
|
|
99
|
+
|
|
100
|
+
parts = ["draining"]
|
|
101
|
+
parts << "#{plural(drained.handed, "live call")} handed over" if drained.handed.positive?
|
|
102
|
+
parts << "#{plural(drained.parked, "live call")} kept for the next process" if drained.parked.positive?
|
|
103
|
+
parts << "#{plural(drained.finished, "tool")} finished" if drained.finished.positive?
|
|
104
|
+
parts << "#{plural(drained.tools - drained.finished, "tool")} cut" if drained.tools > drained.finished
|
|
105
|
+
parts.join(" · ")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def plural(count, noun) = "#{count} #{noun}#{count == 1 ? "" : "s"}"
|
|
109
|
+
|
|
110
|
+
def client_from(env, prod:)
|
|
111
|
+
url, key = env["PINECALL_URL"], env["PINECALL_KEY"]
|
|
112
|
+
raise CannotServe, NO_DOOR if url.to_s.empty? || key.to_s.empty?
|
|
113
|
+
|
|
114
|
+
world = prod ? "production" : env["PINECALL_ENV"]
|
|
115
|
+
world = nil if world.to_s.empty?
|
|
116
|
+
raise CannotServe, "PINECALL_ENV is sandbox or production, not #{world}" unless world.nil? || WORLDS.include?(world)
|
|
117
|
+
|
|
118
|
+
Client.new(url:, api_key: key, env: world)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Listening starts before connect, so `agent.registered` is the first line of a pipe. A pipe's
|
|
122
|
+
# $stdout is buffered: each line is flushed, or the CLI waiting on it sees it only at exit.
|
|
123
|
+
def said(client, out:, err:, events:)
|
|
124
|
+
client.on_entries do |entry|
|
|
125
|
+
if events
|
|
126
|
+
out.puts(JSON.generate({ type: entry.type, agent: entry.agent, call: entry.call, data: entry.data }))
|
|
127
|
+
elsif entry.type == "agent.registered"
|
|
128
|
+
out.puts("#{entry.agent} · answering as #{entry.data[:app]}")
|
|
129
|
+
end
|
|
130
|
+
out.flush
|
|
131
|
+
end
|
|
132
|
+
client.on_errors { |error| err.puts("#{error.class}: #{error.message}") }
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A trap only pushes: the main thread does the leaving, as a trap may not take a lock.
|
|
136
|
+
def trapped
|
|
137
|
+
asked = Thread::Queue.new
|
|
138
|
+
%w[INT TERM].each { |signal| Signal.trap(signal) { asked.push(:signalled) } }
|
|
139
|
+
asked
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The process that started this one is gone when its end of the pipe closes.
|
|
143
|
+
def ended(input, asked)
|
|
144
|
+
input.read
|
|
145
|
+
rescue IOError, SystemCallError
|
|
146
|
+
nil
|
|
147
|
+
ensure
|
|
148
|
+
asked.push(:ended)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The first reason drains (unless the org already stopped it); only a second signal closes at
|
|
152
|
+
# once. The end of stdin is not one: the CLI passes a signal on and closes the pipe together.
|
|
153
|
+
def leave(held, asked, err)
|
|
154
|
+
if asked.pop == :stopped
|
|
155
|
+
held.close
|
|
156
|
+
return 0
|
|
157
|
+
end
|
|
158
|
+
outcome = Thread::Queue.new
|
|
159
|
+
Thread.new { outcome.push(held.stop) }
|
|
160
|
+
Thread.new do
|
|
161
|
+
nil until asked.pop == :signalled
|
|
162
|
+
outcome.push(:again)
|
|
163
|
+
end
|
|
164
|
+
drained = outcome.pop
|
|
165
|
+
drained == :again ? held.close : err.puts(drain_line(drained))
|
|
166
|
+
0
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pinecall"
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
# An in-process fake gateway for offline (ring 0) tests.
|
|
7
|
+
#
|
|
8
|
+
# Answers register/configure, records every command, and lets a test drive calls, tool calls
|
|
9
|
+
# and events.
|
|
10
|
+
#
|
|
11
|
+
# pc = Pinecall::Testing::Gateway.new
|
|
12
|
+
# Pinecall.mount(ClinicaNorte, client: pc)
|
|
13
|
+
# call = pc.call_started(from: "+34600123456")
|
|
14
|
+
# call.tool("find_patient", name: "Marta Ruiz", phone: "+34600123456")
|
|
15
|
+
# assert_includes call.prompt, "Marta Ruiz"
|
|
16
|
+
module Testing
|
|
17
|
+
# A call driven by a test.
|
|
18
|
+
class Fake
|
|
19
|
+
attr_reader :id, :gateway
|
|
20
|
+
|
|
21
|
+
def initialize(gateway, id)
|
|
22
|
+
@gateway = gateway
|
|
23
|
+
@id = id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Simulate a tool call; returns the `tool.result` data.
|
|
27
|
+
def tool(name, **arguments)
|
|
28
|
+
call_id = "tc_#{@gateway.next_seq}"
|
|
29
|
+
@gateway.deliver("tool.call", { call_id:, name: name.to_s, arguments: }, call: @id)
|
|
30
|
+
@gateway.settled { |sent| sent.type == "tool.result" && sent.data[:call_id] == call_id }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Deliver an external event from the backend (`from: "app"`) or a participant.
|
|
34
|
+
def fact(name, data = {}, from: "app", identity: nil)
|
|
35
|
+
payload = { name: name.to_s, data:, source: from.to_s }
|
|
36
|
+
payload[:identity] = identity unless identity.nil?
|
|
37
|
+
@gateway.deliver("event.received", payload, call: @id)
|
|
38
|
+
@gateway.settle
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Deliver a caller turn.
|
|
42
|
+
def said(text)
|
|
43
|
+
@gateway.deliver("turn.user", { text:, speech_id: "sp_#{@gateway.next_seq}", metrics: {} }, call: @id)
|
|
44
|
+
@gateway.settle
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Deliver `call.ended`.
|
|
48
|
+
def ended(reason: "caller_hung_up")
|
|
49
|
+
@gateway.deliver("call.ended", { reason:, ended_by: "caller", ended_at: Time.now.to_f, duration_s: 1.0 },
|
|
50
|
+
call: @id)
|
|
51
|
+
@gateway.settle
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The last `view` block sent.
|
|
55
|
+
def prompt = block("view")
|
|
56
|
+
|
|
57
|
+
# The last text sent for a prompt block.
|
|
58
|
+
def block(name) = last("prompt.set", name: name.to_s)&.dig(:text)
|
|
59
|
+
|
|
60
|
+
# Names of the currently visible tools.
|
|
61
|
+
def tools = (last("tools.set")&.dig(:tools) || []).map { |spec| spec[:name] }
|
|
62
|
+
|
|
63
|
+
# The last state sent.
|
|
64
|
+
def state = last("state.set")&.dig(:state) || {}
|
|
65
|
+
|
|
66
|
+
# Commands sent for this call, in order.
|
|
67
|
+
def commands = @gateway.commands.select { |sent| sent.call == @id }
|
|
68
|
+
|
|
69
|
+
# Data of the last command of `type` whose fields match `matching`.
|
|
70
|
+
def last(type, **matching)
|
|
71
|
+
commands.reverse.find do |sent|
|
|
72
|
+
sent.type == type && matching.all? { |field, value| sent.data[field] == value }
|
|
73
|
+
end&.data
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Stands in for `Pinecall::Client` in `Pinecall.mount`.
|
|
78
|
+
class Gateway
|
|
79
|
+
attr_reader :commands, :agents
|
|
80
|
+
|
|
81
|
+
def initialize
|
|
82
|
+
@commands = []
|
|
83
|
+
@agents = {}
|
|
84
|
+
@seq = 0
|
|
85
|
+
@errors = []
|
|
86
|
+
@found = []
|
|
87
|
+
@searched = []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The chunks every search of this gateway answers with: `{ path:, heading:, text: }`.
|
|
91
|
+
def finds(*chunks)
|
|
92
|
+
@found = chunks
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Every search asked of this gateway, in order: `{ call:, query:, k: }`.
|
|
97
|
+
attr_reader :searched
|
|
98
|
+
|
|
99
|
+
# Same signature as `Pinecall::Client#search`.
|
|
100
|
+
def search(call, query, k: nil)
|
|
101
|
+
@searched << { call:, query:, k: }
|
|
102
|
+
@found
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sdk = "pinecall-ruby-testing/#{VERSION}"
|
|
106
|
+
|
|
107
|
+
# Same signature as `Pinecall::Client#agent`.
|
|
108
|
+
def agent(slug, **options)
|
|
109
|
+
@agents[slug] = Client::Agent.new(slug, options, self)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Record a command and answer it if the gateway would.
|
|
113
|
+
def send_command(type:, agent:, call:, data:, id: nil)
|
|
114
|
+
frame = Wire.command(type:, agent:, call:, data:, id:)
|
|
115
|
+
@commands << frame
|
|
116
|
+
answer(frame)
|
|
117
|
+
frame
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def seen(_event, _call) = nil
|
|
121
|
+
|
|
122
|
+
def on_error(error)
|
|
123
|
+
@errors << error
|
|
124
|
+
warn("pinecall testing: #{error.class}: #{error.message}")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Unhandled errors, for assertions.
|
|
128
|
+
attr_reader :errors
|
|
129
|
+
|
|
130
|
+
# Start a call and return its `Fake` handle; `state:` is the state its opener asked for.
|
|
131
|
+
def call_started(id: nil, channel: "web", from: "+34600000000", to: "+34910000000", caller: nil, state: nil)
|
|
132
|
+
id ||= "CA_#{next_seq}"
|
|
133
|
+
started = { channel:, direction: "inbound", from:, to:, caller:, started_at: Time.now.to_f }
|
|
134
|
+
deliver("call.started", state.nil? ? started : started.merge(state:), call: id)
|
|
135
|
+
settle
|
|
136
|
+
Fake.new(self, id)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Hand this process a call mid-conversation, in `state`; returns its `Fake` handle.
|
|
140
|
+
def call_attached(state:, id: nil, from: "+34600000000", claimed: nil)
|
|
141
|
+
id ||= "CA_#{next_seq}"
|
|
142
|
+
started = { channel: "web", direction: "inbound", from:, to: "+34910000000", caller: nil, started_at: Time.now.to_f }
|
|
143
|
+
deliver("call.attached", { app: "app_test", started:, state:, seq: next_seq, claimed: }, call: id)
|
|
144
|
+
settle
|
|
145
|
+
Fake.new(self, id)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Deliver an entry to the agent.
|
|
149
|
+
def deliver(type, data, call: nil, agent: @agents.keys.first)
|
|
150
|
+
entry = Wire::Entry.new(seq: next_seq, ts: Time.now.to_f, call:, agent:, type:,
|
|
151
|
+
ephemeral: Wire::Codec.ephemeral?(type), data:)
|
|
152
|
+
@agents[agent]&.take(entry)
|
|
153
|
+
entry
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Wait until the call threads are idle.
|
|
157
|
+
def settle(within_s: 2)
|
|
158
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within_s
|
|
159
|
+
sleep(0.002) while Thread.list.count { |thread| thread.status == "run" } > 1 &&
|
|
160
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline
|
|
161
|
+
sleep(0.01)
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Wait for a command matching the block and return its data.
|
|
166
|
+
def settled(within_s: 2)
|
|
167
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within_s
|
|
168
|
+
loop do
|
|
169
|
+
found = @commands.reverse.find { |sent| yield(sent) }
|
|
170
|
+
return found.data unless found.nil?
|
|
171
|
+
raise Error, "the app never sent it" if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
172
|
+
|
|
173
|
+
sleep(0.005)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def next_seq = @seq += 1
|
|
178
|
+
|
|
179
|
+
# Like the real gateway, only register/configure get a reply.
|
|
180
|
+
def answer(frame)
|
|
181
|
+
case frame.type
|
|
182
|
+
when "agent.register"
|
|
183
|
+
deliver("agent.registered", { app: "app_test", routes: frame.data[:routes] }, agent: frame.agent)
|
|
184
|
+
when "agent.configure"
|
|
185
|
+
deliver("agent.configured", { changed: frame.data[:config].keys.map(&:to_s) }, agent: frame.agent)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "erb"
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
# An ERB template for the dynamic region of the prompt, rendered with the state in scope.
|
|
7
|
+
#
|
|
8
|
+
# <% if stage == :identify -%>
|
|
9
|
+
# Saluda y pide nombre y teléfono.
|
|
10
|
+
# <% end -%>
|
|
11
|
+
#
|
|
12
|
+
# The view carries the operator's authority, so never splice external content (memory,
|
|
13
|
+
# knowledge results) into it; that reaches the model as tool results in the history.
|
|
14
|
+
#
|
|
15
|
+
# In scope: every state field by name, `resumed`, `call[:channel]`, `call[:claimed]` (the page
|
|
16
|
+
# code this call claimed, or nil), `remembers?(text)`.
|
|
17
|
+
class View
|
|
18
|
+
# Default location: `views/<slug>.erb` next to the class's file.
|
|
19
|
+
def self.beside(file, slug) = File.join(File.dirname(file), "views", "#{slug}.erb")
|
|
20
|
+
|
|
21
|
+
# Load a template file; the path is kept so errors report the template line.
|
|
22
|
+
def self.file(path)
|
|
23
|
+
raise Error, "there is no view at #{path}" unless File.exist?(path)
|
|
24
|
+
|
|
25
|
+
new(File.read(path), path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.inline(template, called = "(inline view)") = new(template, called)
|
|
29
|
+
|
|
30
|
+
attr_reader :path
|
|
31
|
+
|
|
32
|
+
def initialize(template, path)
|
|
33
|
+
@path = path
|
|
34
|
+
@erb = ERB.new(template, trim_mode: "-")
|
|
35
|
+
@erb.filename = path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def render(reading) = tidy(@erb.result(Context.new(reading).binding_for_the_template))
|
|
39
|
+
|
|
40
|
+
# Strip trailing spaces, collapse blank-line runs, trim both ends.
|
|
41
|
+
def tidy(text)
|
|
42
|
+
text.lines.map { |line| line.rstrip + "\n" }.join.gsub(/\n{3,}/, "\n\n").strip
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Template scope: state fields by name plus a few helpers; unknown names raise.
|
|
46
|
+
class Context
|
|
47
|
+
def initialize(reading)
|
|
48
|
+
@reading = reading
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def binding_for_the_template = binding
|
|
52
|
+
|
|
53
|
+
# Join items one per line.
|
|
54
|
+
def each_line(items) = Array(items).map { |item| item.to_s.strip }.join("\n")
|
|
55
|
+
|
|
56
|
+
# The whole reading, to pass on.
|
|
57
|
+
def state = @reading
|
|
58
|
+
|
|
59
|
+
def respond_to_missing?(name, include_private = false)
|
|
60
|
+
@reading.respond_to?(name) || super
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def method_missing(name, *args, &block)
|
|
64
|
+
return @reading.public_send(name, *args, &block) if @reading.respond_to?(name)
|
|
65
|
+
|
|
66
|
+
super
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|