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,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