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,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Agent
|
|
5
|
+
# Class-level configuration (state lives on the instance).
|
|
6
|
+
#
|
|
7
|
+
# A class declares its contract: tools, state, view. Runtime settings (voice, models,
|
|
8
|
+
# language, greeting, memory, knowledge) belong to the world and are refused at load with the
|
|
9
|
+
# CLI command that sets them.
|
|
10
|
+
module Config
|
|
11
|
+
# `phone`, `whatsapp` and `web` are accepted for compatibility but ignored. `channel_rules
|
|
12
|
+
# false` leaves the `<channel>` block out of the prompt.
|
|
13
|
+
AS_WRITTEN = %i[phone whatsapp web channel_rules].freeze
|
|
14
|
+
|
|
15
|
+
# Settings that moved to the world, and the command that sets each. Must match the
|
|
16
|
+
# TypeScript package's `THE_WORLDS`.
|
|
17
|
+
THE_WORLDS = {
|
|
18
|
+
voice: "pinecall agent set --voice <name>",
|
|
19
|
+
llm: "pinecall agent set --llm <vendor/model>",
|
|
20
|
+
stt: "pinecall agent set --stt <vendor>",
|
|
21
|
+
language: "pinecall agent set --language <tag>",
|
|
22
|
+
greeting: "pinecall agent set --greeting '…' (or --reply '…')",
|
|
23
|
+
hangup: "pinecall agent set --hangup '…'",
|
|
24
|
+
says: "pinecall lexicon add <word> --say '…'",
|
|
25
|
+
hears: "pinecall lexicon hear <word> …",
|
|
26
|
+
memory: "pinecall memory policy --remember '…' --forget '…'",
|
|
27
|
+
record: "pinecall agent set --record on|off",
|
|
28
|
+
knowledge: "pinecall agent knowledge edit — what the agent knows by heart is a setting, not a file",
|
|
29
|
+
docs: "pinecall docs push, then pinecall docs attach <base>"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
NOTHING = Object.new.freeze
|
|
33
|
+
|
|
34
|
+
def self.moved_to_the_world(field)
|
|
35
|
+
"`#{field}` is the world's now, not the class's: #{THE_WORLDS.fetch(field)} — remove it from the class"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
module Declaring
|
|
39
|
+
# Called with no argument, each reads the (possibly inherited) value.
|
|
40
|
+
AS_WRITTEN.each do |field|
|
|
41
|
+
define_method(field) do |value = NOTHING|
|
|
42
|
+
return config[field] if value.equal?(NOTHING)
|
|
43
|
+
|
|
44
|
+
config[field] = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# World settings raise at load, naming the command that sets them.
|
|
49
|
+
THE_WORLDS.each_key do |field|
|
|
50
|
+
define_method(field) do |*_said, **_options|
|
|
51
|
+
raise DeclarationRefused, Config.moved_to_the_world(field)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Set the class docstring explicitly, for classes with no source file.
|
|
56
|
+
def doc(text = NOTHING)
|
|
57
|
+
return Doc.for_class(self) if text.equal?(NOTHING)
|
|
58
|
+
|
|
59
|
+
@pinecall_doc = text
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Accept an external event from `:app` (the application's backend) or `:participant`
|
|
63
|
+
# (a browser). Undeclared events never reach `on_event`.
|
|
64
|
+
def accepts(name, from:)
|
|
65
|
+
declared_events[name.to_s] = Array(from).map(&:to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def declared_events
|
|
69
|
+
@declared_events ||= superclass.respond_to?(:declared_events) ? superclass.declared_events.dup : {}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Declared config, including inherited values.
|
|
73
|
+
def config
|
|
74
|
+
@config ||= superclass.respond_to?(:config) ? superclass.config.dup : {}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The slug the class names itself, or nil when it names none.
|
|
78
|
+
def declared_slug = @slug
|
|
79
|
+
|
|
80
|
+
# Registration slug; defaults to the class name in kebab-case.
|
|
81
|
+
def slug(name = NOTHING)
|
|
82
|
+
return (@slug || default_slug) if name.equal?(NOTHING)
|
|
83
|
+
|
|
84
|
+
@slug = name
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The AgentConfig sent to the gateway.
|
|
88
|
+
def wire_config(tools: nil)
|
|
89
|
+
{
|
|
90
|
+
prompt: Prompt::FRAMEWORK,
|
|
91
|
+
tools:,
|
|
92
|
+
state_fields: state_field_specs,
|
|
93
|
+
events: event_specs,
|
|
94
|
+
# Only the name: the panel is drawn on demand, by `view.render`.
|
|
95
|
+
view: (declared_panel && { name: declared_panel.name }),
|
|
96
|
+
uses_knowledge: (true if Searching.searches?(self))
|
|
97
|
+
}.compact
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def state_field_specs
|
|
101
|
+
declared = state_visibility
|
|
102
|
+
return nil if declared.empty?
|
|
103
|
+
|
|
104
|
+
declared.map { |name, visibility| { name: name.to_s, visibility: visibility.to_s } }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def event_specs
|
|
108
|
+
return nil if declared_events.empty?
|
|
109
|
+
|
|
110
|
+
declared_events.map { |name, from| { name:, from: } }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def default_slug
|
|
116
|
+
(name || "agent").split("::").last
|
|
117
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1-\2')
|
|
118
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1-\2')
|
|
119
|
+
.downcase
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Agent
|
|
5
|
+
# Reads the comment above a class or method from its source file; that comment is the prompt.
|
|
6
|
+
#
|
|
7
|
+
# Ruby drops `#` comments, so this uses `source_location` and walks up from the `def`,
|
|
8
|
+
# skipping declaration macros in between:
|
|
9
|
+
#
|
|
10
|
+
# # Busca al paciente por nombre y teléfono.
|
|
11
|
+
# tool stage: :identify, pii: %i[name phone]
|
|
12
|
+
# def find_patient(name:, phone:)
|
|
13
|
+
#
|
|
14
|
+
# A class built at runtime has no source file; it declares its text with `doc "…"`.
|
|
15
|
+
module Doc
|
|
16
|
+
# Stops the upward walk when no comment was found.
|
|
17
|
+
ENDS_WHAT_CAME_BEFORE = /\A\s*(end\b|def\b|class\b|module\b)/
|
|
18
|
+
COMMENT = /\A\s*#(?!\s*(frozen_string_literal|rubocop|:nodoc:))\s?(.*)\z/
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# The comment above the class's `class` line, or nil.
|
|
22
|
+
def for_class(klass)
|
|
23
|
+
declared = klass.instance_variable_get(:@pinecall_doc)
|
|
24
|
+
return declared unless declared.nil?
|
|
25
|
+
return nil if klass.name.nil?
|
|
26
|
+
|
|
27
|
+
where = Object.const_source_location(klass.name)
|
|
28
|
+
where.nil? ? nil : above(*where)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The comment above one method of a class, or nil.
|
|
32
|
+
def for_method(klass, name)
|
|
33
|
+
where = klass.instance_method(name).source_location
|
|
34
|
+
where.nil? ? nil : above(*where)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The comment block ending just above `line` of `file`, or nil.
|
|
38
|
+
def above(file, line)
|
|
39
|
+
lines = source(file)
|
|
40
|
+
return nil if lines.empty?
|
|
41
|
+
|
|
42
|
+
# Skip the declaration lines above the `def`; a blank line ends the walk, so the
|
|
43
|
+
# comment must sit directly above.
|
|
44
|
+
at = line - 2
|
|
45
|
+
at -= 1 while at >= 0 && !lines[at].match?(COMMENT) &&
|
|
46
|
+
!lines[at].strip.empty? && !lines[at].match?(ENDS_WHAT_CAME_BEFORE)
|
|
47
|
+
block = []
|
|
48
|
+
while at >= 0 && (said = lines[at].match(COMMENT))
|
|
49
|
+
block.unshift(said[2].rstrip)
|
|
50
|
+
at -= 1
|
|
51
|
+
end
|
|
52
|
+
one_line(block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A comment as the model reads it: one line, its blank lines dropped, ending at the first
|
|
56
|
+
# tag line (`@param`). Word for word what the TypeScript package does to a JSDoc.
|
|
57
|
+
def one_line(block)
|
|
58
|
+
said = block.map(&:strip).take_while { |line| !line.start_with?("@") }.reject(&:empty?)
|
|
59
|
+
said.empty? ? nil : said.join(" ")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Cached per process so a running agent never reads the disk between turns.
|
|
63
|
+
def source(file)
|
|
64
|
+
@source ||= {}
|
|
65
|
+
@source[file] ||= File.exist?(file) ? File.readlines(file, chomp: true) : []
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Clear the cache; for tests that rewrite and reload a file.
|
|
69
|
+
def forget = @source = {}
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What a class reaches its bases through: one verb, answered by the gateway for the call in hand.
|
|
4
|
+
|
|
5
|
+
module Pinecall
|
|
6
|
+
class Agent
|
|
7
|
+
# `knowledge.search("…", k: 3)` inside a tool: the bases attached to the agent, searched for
|
|
8
|
+
# this call by the gateway, which logs what it found.
|
|
9
|
+
class Knowledge
|
|
10
|
+
def initialize(agent)
|
|
11
|
+
@agent = agent
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The chunks found, each a `CallWorld::Found`.
|
|
15
|
+
def search(query, k: nil) = @agent.call.search(query, k:)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ripper"
|
|
4
|
+
|
|
5
|
+
# Whether a class searches its knowledge bases, read off its own source with Ripper's tokens.
|
|
6
|
+
|
|
7
|
+
module Pinecall
|
|
8
|
+
class Agent
|
|
9
|
+
# A token walk, not a regex: `knowledge.search` inside a string or a comment is one token of
|
|
10
|
+
# another kind, and does not count. Any method of the file counts, not only tools.
|
|
11
|
+
module Searching
|
|
12
|
+
SEARCHES = [%w[knowledge . search], %w[call . search]].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Whether the class's file calls `knowledge.search` or `call.search`. Sent at registration as
|
|
17
|
+
# `uses_knowledge`, so a world with no base attached is refused when the agent registers.
|
|
18
|
+
def searches?(klass)
|
|
19
|
+
file = Object.const_source_location(klass.name.to_s)&.first
|
|
20
|
+
return false if file.nil? || !File.exist?(file)
|
|
21
|
+
|
|
22
|
+
in_source?(File.read(file))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def in_source?(source)
|
|
26
|
+
words = Ripper.lex(source).filter_map do |(_, kind, token)|
|
|
27
|
+
token if %i[on_ident on_period on_op].include?(kind) || (kind == :on_kw && token == "self")
|
|
28
|
+
end
|
|
29
|
+
words.each_cons(3).any? { |three| SEARCHES.include?(three) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Agent
|
|
5
|
+
# Builds and validates a tool's wire spec.
|
|
6
|
+
#
|
|
7
|
+
# A model fills arguments by name, so tools take keyword arguments only. Names come from the
|
|
8
|
+
# method signature, types from `params:`.
|
|
9
|
+
module Spec
|
|
10
|
+
A_NAME_A_MODEL_CAN_CALL = /\A[A-Za-z][A-Za-z0-9_]*\z/
|
|
11
|
+
|
|
12
|
+
# Schema for a parameter with no declared type.
|
|
13
|
+
TEXT = { type: "string" }.freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Rewrite `stage:` as a `when`, so visibility has a single form.
|
|
17
|
+
def lower(klass, name, options)
|
|
18
|
+
wanted = options[:stage]
|
|
19
|
+
return options if wanted.nil?
|
|
20
|
+
|
|
21
|
+
wanted = Array(wanted).map(&:to_sym)
|
|
22
|
+
refuse_a_stage_the_class_does_not_declare(klass, name, wanted)
|
|
23
|
+
asked = options[:when]
|
|
24
|
+
in_stage = ->(state) { wanted.include?(state[:stage]) && (asked.nil? || Tools.ask(asked, state)) }
|
|
25
|
+
options.merge(when: in_stage)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Build the wire spec, refusing what the gateway would refuse.
|
|
29
|
+
def build(klass, name, options)
|
|
30
|
+
refuse_a_name_no_model_can_call(name)
|
|
31
|
+
method = klass.instance_method(name)
|
|
32
|
+
spec = {
|
|
33
|
+
name: name.to_s,
|
|
34
|
+
description: description!(klass, name, options),
|
|
35
|
+
parameters: parameters(method, options[:params] || {}),
|
|
36
|
+
side_effect: options[:confirm] ? "irreversible" : "read"
|
|
37
|
+
}
|
|
38
|
+
spec[:confirm] = options[:confirm] if options[:confirm]
|
|
39
|
+
spec[:pii] = pii!(options, spec) if options[:pii]
|
|
40
|
+
spec[:timeout_s] = options[:timeout].to_f if options[:timeout]
|
|
41
|
+
Wire::Validate.call!("ToolSpec", spec, where: "tool #{name}")
|
|
42
|
+
spec.freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# JSON Schema from the method's keyword names and declared types.
|
|
46
|
+
def parameters(method, declared)
|
|
47
|
+
properties = {}
|
|
48
|
+
required = []
|
|
49
|
+
method.parameters.each do |kind, called|
|
|
50
|
+
next if kind == :block
|
|
51
|
+
|
|
52
|
+
refuse_an_argument_a_model_cannot_fill(method, kind, called)
|
|
53
|
+
properties[called] = schema_of(declared[called])
|
|
54
|
+
required << called.to_s if kind == :keyreq
|
|
55
|
+
end
|
|
56
|
+
{ type: "object", properties:, required:, additionalProperties: false }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Accepts a class, a one-element array of a class, or a raw JSON Schema hash.
|
|
60
|
+
def schema_of(declared)
|
|
61
|
+
case declared
|
|
62
|
+
when nil then TEXT
|
|
63
|
+
when Hash then declared
|
|
64
|
+
when Array then { type: "array", items: schema_of(declared.first) }
|
|
65
|
+
when Class then of_class(declared)
|
|
66
|
+
else TEXT
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def of_class(klass)
|
|
71
|
+
case klass.name
|
|
72
|
+
when "Integer" then { type: "integer" }
|
|
73
|
+
when "Float", "Numeric" then { type: "number" }
|
|
74
|
+
when "TrueClass", "FalseClass" then { type: "boolean" }
|
|
75
|
+
when "Array" then { type: "array", items: TEXT }
|
|
76
|
+
when "Hash" then { type: "object", additionalProperties: true }
|
|
77
|
+
else TEXT
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# ── validation ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def description!(klass, name, options)
|
|
84
|
+
said = options[:doc] || Doc.for_method(klass, name)
|
|
85
|
+
return said unless said.nil? || said.empty?
|
|
86
|
+
|
|
87
|
+
raise DeclarationRefused,
|
|
88
|
+
"tool #{name}: without a docstring no model can choose it; write a `# one line` " \
|
|
89
|
+
"comment above it, or pass doc: \"…\" for a class with no source to read"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def pii!(options, spec)
|
|
93
|
+
wanted = Array(options[:pii]).map(&:to_sym)
|
|
94
|
+
unknown = wanted - spec[:parameters][:properties].keys
|
|
95
|
+
return wanted.map(&:to_s) if unknown.empty?
|
|
96
|
+
|
|
97
|
+
raise DeclarationRefused,
|
|
98
|
+
"tool #{spec[:name]}: pii names parameters the tool has; unknown: #{unknown.join(", ")}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def refuse_a_name_no_model_can_call(name)
|
|
102
|
+
return if A_NAME_A_MODEL_CAN_CALL.match?(name.to_s)
|
|
103
|
+
|
|
104
|
+
raise DeclarationRefused, "a tool name is one word a model can call, not #{name}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def refuse_an_argument_a_model_cannot_fill(method, kind, called)
|
|
108
|
+
return if %i[key keyreq].include?(kind)
|
|
109
|
+
|
|
110
|
+
raise DeclarationRefused,
|
|
111
|
+
"tool #{method.name}: a model fills a JSON object by name, so a tool takes " \
|
|
112
|
+
"keyword arguments; #{called || kind} is not one"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def refuse_a_stage_the_class_does_not_declare(klass, name, wanted)
|
|
116
|
+
declared = klass.stages
|
|
117
|
+
if declared.nil?
|
|
118
|
+
raise DeclarationRefused,
|
|
119
|
+
"tool #{name}: stage names a value of this agent's own stage field, and " \
|
|
120
|
+
"#{klass.name || "this class"} declares none; add `stage :identify, :book` to " \
|
|
121
|
+
"the class, or ask `when:` instead"
|
|
122
|
+
end
|
|
123
|
+
unknown = wanted - declared
|
|
124
|
+
return if unknown.empty?
|
|
125
|
+
|
|
126
|
+
raise DeclarationRefused,
|
|
127
|
+
"tool #{name}: #{unknown.join(", ")} is not one of this agent's stages " \
|
|
128
|
+
"(#{declared.join(", ")})"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Agent
|
|
5
|
+
# One recorded field assignment.
|
|
6
|
+
Change = Data.define(:seq, :field, :prev, :next, :author, :at)
|
|
7
|
+
|
|
8
|
+
# One named entry the agent appended to the call's log.
|
|
9
|
+
LogEntry = Data.define(:seq, :name, :data, :at)
|
|
10
|
+
|
|
11
|
+
# Declared state fields. The writer each `state` macro generates records every change.
|
|
12
|
+
module State
|
|
13
|
+
module Declaring
|
|
14
|
+
# Declare a state field and its initial value.
|
|
15
|
+
#
|
|
16
|
+
# state :patient, visibility: :pii # nil until a tool finds one
|
|
17
|
+
# state :slots, [] # a list of its own, per call
|
|
18
|
+
# state(:identified) { !patient.nil? }
|
|
19
|
+
#
|
|
20
|
+
# A block makes the field derived: readable like any other, never assignable.
|
|
21
|
+
#
|
|
22
|
+
# @param visibility [Symbol] `:public`, `:tenant` or `:pii`; omitted means the wire
|
|
23
|
+
# default (`tenant`) and nothing is sent.
|
|
24
|
+
def state(name, default = nil, visibility: nil, &derived)
|
|
25
|
+
name = name.to_sym
|
|
26
|
+
refuse_a_name_that_is_taken(name)
|
|
27
|
+
state_visibility[name] = visibility unless visibility.nil?
|
|
28
|
+
if derived
|
|
29
|
+
derived_state[name] = derived
|
|
30
|
+
define_method(name) { instance_exec(&self.class.derived_state[name]) }
|
|
31
|
+
return name
|
|
32
|
+
end
|
|
33
|
+
declared_state[name] = default
|
|
34
|
+
define_method(name) { @state[name] }
|
|
35
|
+
define_method(:"#{name}=") { |value| write_state(name, value) }
|
|
36
|
+
name
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Declare the `stage` field and its values; the first is the initial one.
|
|
40
|
+
#
|
|
41
|
+
# stage :identify, :choose, :book, :done
|
|
42
|
+
#
|
|
43
|
+
# A tool's `stage:` naming an undeclared stage is refused at load, since that tool
|
|
44
|
+
# could never be visible.
|
|
45
|
+
def stage(*names)
|
|
46
|
+
raise DeclarationRefused, "stage names the values the stage may hold; give at least one" if names.empty?
|
|
47
|
+
|
|
48
|
+
@stages = names.map(&:to_sym)
|
|
49
|
+
state(:stage, @stages.first)
|
|
50
|
+
@stages
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The declared stage values, inherited if not declared here.
|
|
54
|
+
def stages
|
|
55
|
+
@stages || (superclass.respond_to?(:stages) ? superclass.stages : nil)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Declared fields and initial values, including inherited ones.
|
|
59
|
+
def declared_state
|
|
60
|
+
@declared_state ||= inherit(:declared_state)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Derived fields by name.
|
|
64
|
+
def derived_state
|
|
65
|
+
@derived_state ||= inherit(:derived_state)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Visibility by field, for fields that declared one.
|
|
69
|
+
def state_visibility
|
|
70
|
+
@state_visibility ||= inherit(:state_visibility)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def state_names = declared_state.keys + derived_state.keys
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def inherit(what)
|
|
78
|
+
superclass.respond_to?(what) ? superclass.public_send(what).dup : {}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def refuse_a_name_that_is_taken(name)
|
|
82
|
+
return unless declared_state.key?(name) || derived_state.key?(name)
|
|
83
|
+
|
|
84
|
+
raise DeclarationRefused, "#{self.name || "this agent"} declares #{name} twice"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ── instance methods ─────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
# A copy of the current state, derived fields included.
|
|
91
|
+
def snapshot
|
|
92
|
+
kept = @state.dup
|
|
93
|
+
self.class.derived_state.each_key { |name| kept[name] = public_send(name) }
|
|
94
|
+
kept
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Replace the state with a snapshot; fields missing from it are cleared. A stage that came
|
|
98
|
+
# back as JSON is a string, and is the declared stage it names.
|
|
99
|
+
def restore(state)
|
|
100
|
+
state = state.transform_keys(&:to_sym)
|
|
101
|
+
Author.with(Author.current || "restore") do
|
|
102
|
+
self.class.declared_state.each_key do |name|
|
|
103
|
+
value = state.key?(name) ? state[name] : nil
|
|
104
|
+
write_state(name, name == :stage ? declared_stage(value) : value)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Merge the given fields over the current state (unlike `restore`, which clears the rest).
|
|
112
|
+
def start_in(state)
|
|
113
|
+
restore(snapshot.merge(state.transform_keys(&:to_sym)))
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Fields that differ between two snapshots.
|
|
117
|
+
def diff(before, after = snapshot)
|
|
118
|
+
(before.keys | after.keys).filter_map do |field|
|
|
119
|
+
{ field:, prev: before[field], next: after[field] } if before[field] != after[field]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Replace the change log with one summary entry; the state itself is untouched.
|
|
124
|
+
def collapse(summary)
|
|
125
|
+
at = now
|
|
126
|
+
@changes.clear
|
|
127
|
+
@changes << Change.new(seq: next_seq, field: "@summary", prev: nil, next: summary,
|
|
128
|
+
author: "collapse", at:)
|
|
129
|
+
@changes.last
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Recorded changes, oldest first.
|
|
133
|
+
def changes = @changes.dup
|
|
134
|
+
|
|
135
|
+
# Subscribe to changes; the returned lambda unsubscribes.
|
|
136
|
+
def on_change(&listener)
|
|
137
|
+
@change_listeners << listener
|
|
138
|
+
-> { @change_listeners.delete(listener) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Start recording changes; earlier writes are the baseline.
|
|
142
|
+
def seal
|
|
143
|
+
@sealed = true
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def sealed? = @sealed
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def declared_stage(value)
|
|
152
|
+
named = self.class.stages&.find { |stage| stage.to_s == value.to_s }
|
|
153
|
+
named.nil? ? value : named
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Every state write goes through here; after `seal` a write without an author raises.
|
|
157
|
+
def write_state(field, value)
|
|
158
|
+
if @sealed
|
|
159
|
+
author = Author.current
|
|
160
|
+
raise UnauthoredWrite, field if author.nil?
|
|
161
|
+
|
|
162
|
+
previous = @state[field]
|
|
163
|
+
@state[field] = value
|
|
164
|
+
# An equal value is not a change: no re-render, no log entry.
|
|
165
|
+
return value if previous == value
|
|
166
|
+
|
|
167
|
+
record(Change.new(seq: next_seq, field:, prev: previous, next: value, author:, at: now))
|
|
168
|
+
else
|
|
169
|
+
@state[field] = value
|
|
170
|
+
end
|
|
171
|
+
value
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def record(change)
|
|
175
|
+
@changes << change
|
|
176
|
+
@change_listeners.each { |listener| listener.call(change) }
|
|
177
|
+
change
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def next_seq = @seq += 1
|
|
181
|
+
|
|
182
|
+
def now = Time.now.to_f
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pinecall
|
|
4
|
+
class Agent
|
|
5
|
+
# One declared tool: its local options and the spec sent on the wire.
|
|
6
|
+
Declaration = Data.define(:name, :options, :spec)
|
|
7
|
+
|
|
8
|
+
# Tool declaration and visibility. `tool` marks the next method defined, like Sorbet's `sig`.
|
|
9
|
+
module Tools
|
|
10
|
+
module Declaring
|
|
11
|
+
# Declare the next method as a tool.
|
|
12
|
+
#
|
|
13
|
+
# # Reserva la hora que el paciente ya ha confirmado.
|
|
14
|
+
# tool stage: :book, confirm: "Le reservo el {{chosen}}. ¿Lo confirmo?"
|
|
15
|
+
# def book(chosen:)
|
|
16
|
+
#
|
|
17
|
+
# @param when [Proc] visibility predicate, evaluated against the state
|
|
18
|
+
# @param stage [Symbol, Array<Symbol>] shorthand for a `when` on the stage field
|
|
19
|
+
# @param confirm [String] read-back spoken before running; marks the tool irreversible
|
|
20
|
+
# @param preview [Integer] rows of a list result the model sees
|
|
21
|
+
# @param pii [Array<Symbol>] parameters with personal data, masked in the log
|
|
22
|
+
# @param timeout [Numeric] seconds the platform waits for the method
|
|
23
|
+
# @param params [Hash{Symbol=>Object}] parameter types, e.g. `{ day: String, how_many: Integer }`
|
|
24
|
+
def tool(**options)
|
|
25
|
+
unknown = options.keys - %i[when stage confirm preview pii timeout params doc]
|
|
26
|
+
raise DeclarationRefused, "@tool takes no #{unknown.join(", ")}" unless unknown.empty?
|
|
27
|
+
|
|
28
|
+
@pending_tool = options
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Attaches a pending `tool` declaration to the method just defined.
|
|
32
|
+
def method_added(name)
|
|
33
|
+
super
|
|
34
|
+
options = @pending_tool
|
|
35
|
+
return if options.nil?
|
|
36
|
+
|
|
37
|
+
@pending_tool = nil
|
|
38
|
+
declare_tool(name, options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Declared tools by name, including inherited ones.
|
|
42
|
+
def declared_tools
|
|
43
|
+
@declared_tools ||= superclass.respond_to?(:declared_tools) ? superclass.declared_tools.dup : {}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tool_named(name) = declared_tools[name.to_sym]
|
|
47
|
+
|
|
48
|
+
# Validate at load what the gateway would refuse later.
|
|
49
|
+
def declare_tool(name, options)
|
|
50
|
+
options = Spec.lower(self, name, options)
|
|
51
|
+
spec = Spec.build(self, name, options)
|
|
52
|
+
declared_tools[name.to_sym] = Declaration.new(name: name.to_sym, options:, spec:)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── instance methods ─────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
# The class comment, sent as the top of the prompt.
|
|
59
|
+
def doc = Doc.for_class(self.class)
|
|
60
|
+
|
|
61
|
+
# Every declared tool spec, visible or not.
|
|
62
|
+
def tools = self.class.declared_tools.values.map(&:spec)
|
|
63
|
+
|
|
64
|
+
# Tool specs whose `when` holds for the current state.
|
|
65
|
+
def visible_tools = visible_declarations.map(&:spec)
|
|
66
|
+
|
|
67
|
+
def visible_declarations
|
|
68
|
+
state = snapshot
|
|
69
|
+
self.class.declared_tools.values.select { |declared| Tools.shows?(declared, state) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Run a tool as the bridge does, with its writes attributed to it.
|
|
73
|
+
def run_tool(name, arguments = {})
|
|
74
|
+
declared = self.class.tool_named(name)
|
|
75
|
+
raise ToolFailed, "#{name}: this agent declares no such tool" if declared.nil?
|
|
76
|
+
|
|
77
|
+
Tools.run(self, declared, arguments)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# `stage:` was already lowered to a `when` at declaration.
|
|
82
|
+
def shows?(declared, state)
|
|
83
|
+
asked = declared.options[:when]
|
|
84
|
+
asked.nil? || ask(asked, state)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A `when` may take the state as an argument (`->(s) { s.slots.any? }`) or be evaluated
|
|
88
|
+
# against it (`-> { slots.any? }`).
|
|
89
|
+
def ask(asked, state)
|
|
90
|
+
reading = state.is_a?(Reading) ? state : Reading.new(state)
|
|
91
|
+
asked.arity.zero? ? reading.instance_exec(&asked) : asked.call(reading)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Check arguments, run the method with its writes attributed, and trim to `preview`.
|
|
95
|
+
def run(agent, declared, arguments)
|
|
96
|
+
named = arguments.to_h { |key, value| [key.to_sym, value] }
|
|
97
|
+
refuse_arguments_the_tool_never_asked_for(declared, named)
|
|
98
|
+
result = Author.with(declared.name.to_s) { agent.public_send(declared.name, **named) }
|
|
99
|
+
preview(result, declared.options[:preview])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Trims only what the model sees; state keeps every row.
|
|
103
|
+
def preview(result, rows)
|
|
104
|
+
return result unless rows.is_a?(Integer) && result.is_a?(Array) && result.size > rows
|
|
105
|
+
|
|
106
|
+
result.first(rows)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def refuse_arguments_the_tool_never_asked_for(declared, named)
|
|
110
|
+
declared_names = (declared.spec[:parameters][:properties] || {}).keys
|
|
111
|
+
unknown = named.keys - declared_names
|
|
112
|
+
return if unknown.empty?
|
|
113
|
+
|
|
114
|
+
raise ToolFailed, "#{declared.name}: it takes no #{unknown.join(", ")}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|