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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +167 -0
  3. data/LICENSE +202 -0
  4. data/README.md +140 -0
  5. data/lib/pinecall/agent/author.rb +31 -0
  6. data/lib/pinecall/agent/config.rb +124 -0
  7. data/lib/pinecall/agent/doc.rb +73 -0
  8. data/lib/pinecall/agent/knowledge.rb +18 -0
  9. data/lib/pinecall/agent/searching.rb +33 -0
  10. data/lib/pinecall/agent/spec.rb +133 -0
  11. data/lib/pinecall/agent/state.rb +185 -0
  12. data/lib/pinecall/agent/tools.rb +119 -0
  13. data/lib/pinecall/agent.rb +160 -0
  14. data/lib/pinecall/blocks.rb +152 -0
  15. data/lib/pinecall/bridge.rb +239 -0
  16. data/lib/pinecall/call_world/answers.rb +61 -0
  17. data/lib/pinecall/call_world/room.rb +25 -0
  18. data/lib/pinecall/call_world.rb +175 -0
  19. data/lib/pinecall/client/agent.rb +179 -0
  20. data/lib/pinecall/client/call.rb +140 -0
  21. data/lib/pinecall/client/connection.rb +190 -0
  22. data/lib/pinecall/client/endpoints.rb +30 -0
  23. data/lib/pinecall/client/listeners.rb +42 -0
  24. data/lib/pinecall/client/observe.rb +109 -0
  25. data/lib/pinecall/client/rest.rb +61 -0
  26. data/lib/pinecall/client.rb +212 -0
  27. data/lib/pinecall/errors.rb +53 -0
  28. data/lib/pinecall/panel.rb +138 -0
  29. data/lib/pinecall/reading.rb +37 -0
  30. data/lib/pinecall/rules.rb +38 -0
  31. data/lib/pinecall/serve/held.rb +40 -0
  32. data/lib/pinecall/serve/loading.rb +102 -0
  33. data/lib/pinecall/serve/viewing.rb +40 -0
  34. data/lib/pinecall/serve.rb +169 -0
  35. data/lib/pinecall/testing.rb +190 -0
  36. data/lib/pinecall/version.rb +6 -0
  37. data/lib/pinecall/view.rb +70 -0
  38. data/lib/pinecall/wire/codec.rb +75 -0
  39. data/lib/pinecall/wire/enums.rb +104 -0
  40. data/lib/pinecall/wire/errors.rb +12 -0
  41. data/lib/pinecall/wire/reduce.rb +249 -0
  42. data/lib/pinecall/wire/registry.rb +168 -0
  43. data/lib/pinecall/wire/shapes.rb +33 -0
  44. data/lib/pinecall/wire/shapes_call_events.rb +137 -0
  45. data/lib/pinecall/wire/shapes_commands.rb +114 -0
  46. data/lib/pinecall/wire/shapes_config.rb +70 -0
  47. data/lib/pinecall/wire/shapes_doors.rb +96 -0
  48. data/lib/pinecall/wire/shapes_events.rb +358 -0
  49. data/lib/pinecall/wire/shapes_metrics.rb +109 -0
  50. data/lib/pinecall/wire/shapes_parts.rb +340 -0
  51. data/lib/pinecall/wire/state.rb +56 -0
  52. data/lib/pinecall/wire/validate.rb +154 -0
  53. data/lib/pinecall/wire.rb +43 -0
  54. data/lib/pinecall.rb +45 -0
  55. data/sig/pinecall.rbs +453 -0
  56. 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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pinecall
4
+ # Set by the maintainer at release time.
5
+ VERSION = "0.0.1"
6
+ 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