llm.rb 12.5.1 → 13.0.0

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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +482 -0
  3. data/LICENSE +21 -93
  4. data/README.md +49 -159
  5. data/data/deepinfra.json +3 -0
  6. data/data/xai.json +1 -1
  7. data/lib/llm/a2a.rb +1 -1
  8. data/lib/llm/active_record/acts_as_agent.rb +32 -0
  9. data/lib/llm/active_record/acts_as_llm.rb +6 -6
  10. data/lib/llm/agent.rb +101 -26
  11. data/lib/llm/buffer.rb +85 -3
  12. data/lib/llm/compactor/null.rb +19 -0
  13. data/lib/llm/compactor/truncate.rb +80 -0
  14. data/lib/llm/compactor.rb +42 -124
  15. data/lib/llm/context.rb +33 -37
  16. data/lib/llm/contract.rb +4 -25
  17. data/lib/llm/function/array.rb +15 -14
  18. data/lib/llm/function/async/group.rb +54 -0
  19. data/lib/llm/function/async/reactor.rb +48 -0
  20. data/lib/llm/function/async/task.rb +83 -0
  21. data/lib/llm/function/fiber/group.rb +46 -0
  22. data/lib/llm/function/fiber/task.rb +62 -0
  23. data/lib/llm/function/{fork_group.rb → fork/group.rb} +14 -5
  24. data/lib/llm/function/fork/job.rb +4 -3
  25. data/lib/llm/function/fork/task.rb +20 -10
  26. data/lib/llm/function/group.rb +40 -0
  27. data/lib/llm/function/{ractor_group.rb → ractor/group.rb} +13 -5
  28. data/lib/llm/function/ractor/job.rb +19 -3
  29. data/lib/llm/function/ractor/mailbox.rb +9 -0
  30. data/lib/llm/function/ractor/task.rb +24 -15
  31. data/lib/llm/function/{call_group.rb → sequential/group.rb} +17 -8
  32. data/lib/llm/function/sequential/task.rb +49 -0
  33. data/lib/llm/function/task.rb +25 -37
  34. data/lib/llm/function/thread/group.rb +46 -0
  35. data/lib/llm/function/thread/task.rb +60 -0
  36. data/lib/llm/function/tracing.rb +2 -0
  37. data/lib/llm/function.rb +56 -64
  38. data/lib/llm/loop_guard.rb +1 -2
  39. data/lib/llm/mcp.rb +22 -0
  40. data/lib/llm/object.rb +2 -1
  41. data/lib/llm/provider.rb +6 -3
  42. data/lib/llm/providers/google.rb +2 -2
  43. data/lib/llm/repl/command.rb +35 -8
  44. data/lib/llm/repl/commands/compact.rb +33 -0
  45. data/lib/llm/repl/input.rb +80 -15
  46. data/lib/llm/repl/markdown/table.rb +76 -0
  47. data/lib/llm/repl/markdown.rb +31 -1
  48. data/lib/llm/repl/status.rb +1 -1
  49. data/lib/llm/repl/stream.rb +10 -3
  50. data/lib/llm/repl/transcript.rb +1 -1
  51. data/lib/llm/repl/walker.rb +46 -0
  52. data/lib/llm/repl.rb +18 -12
  53. data/lib/llm/response.rb +10 -0
  54. data/lib/llm/schema/leaf.rb +5 -0
  55. data/lib/llm/schema/object.rb +11 -5
  56. data/lib/llm/sequel/agent.rb +32 -0
  57. data/lib/llm/sequel/plugin.rb +6 -6
  58. data/lib/llm/stream.rb +24 -17
  59. data/lib/llm/tool/param.rb +12 -0
  60. data/lib/llm/tool.rb +20 -4
  61. data/lib/llm/tools/chdir.rb +0 -2
  62. data/lib/llm/tools/git.rb +8 -4
  63. data/lib/llm/tools/mkdir.rb +1 -1
  64. data/lib/llm/tools/pwd.rb +0 -2
  65. data/lib/llm/tools/read_file.rb +0 -2
  66. data/lib/llm/tools/rg.rb +8 -4
  67. data/lib/llm/tools/shell.rb +8 -4
  68. data/lib/llm/tools/utils.rb +31 -0
  69. data/lib/llm/version.rb +1 -1
  70. data/lib/llm.rb +25 -5
  71. data/llm.gemspec +3 -3
  72. data/resources/deepdive.md +693 -57
  73. metadata +24 -13
  74. data/lib/llm/function/call_task.rb +0 -46
  75. data/lib/llm/function/fiber_group.rb +0 -105
  76. data/lib/llm/function/task_group.rb +0 -97
  77. data/lib/llm/function/thread_group.rb +0 -102
@@ -12,16 +12,30 @@ class LLM::Repl
12
12
  F: Curses::KEY_CTRL_F,
13
13
  K: Curses::KEY_CTRL_K,
14
14
  Y: Curses::KEY_CTRL_Y,
15
- D: Curses::KEY_CTRL_D
15
+ D: Curses::KEY_CTRL_D,
16
+ P: Curses::KEY_CTRL_P,
17
+ N: Curses::KEY_CTRL_N
16
18
  }
17
19
 
20
+ ##
21
+ # This hash tracks how many times a given key
22
+ # was pressed repeatedly without being
23
+ # interrupted by another key. The previous key
24
+ # is reset to 0 when a different key is pressed.
25
+ REPEATS = {}
26
+ REPEATS.default = 0
27
+
18
28
  UP = Curses::Key::UP
19
29
  DOWN = Curses::Key::DOWN
20
30
  LEFT = Curses::Key::LEFT
21
31
  RIGHT = Curses::Key::RIGHT
32
+ PGUP = Curses::KEY_PPAGE
33
+ PGDOWN = Curses::KEY_NPAGE
34
+
35
+ TAB = 9
22
36
  ESC = 27
23
- ENTER = [Curses::Key::ENTER, 10, 13]
24
- BACKSPACE = [Curses::Key::BACKSPACE, 127]
37
+ ENTER = 10
38
+ BACKSPACE = 127
25
39
 
26
40
  ##
27
41
  # Threshold in seconds. If characters arrive faster than
@@ -35,21 +49,24 @@ class LLM::Repl
35
49
  attr_reader :buffer
36
50
 
37
51
  ##
38
- # @param [Boolean] bool
52
+ # @param [Boolean] value
39
53
  # @return [void]
40
54
  attr_writer :paste
41
55
 
42
56
  ##
43
- # @param [LLM::Agent] agent
57
+ # @param [LLM::Repl] repl
44
58
  # @return [LLM::Repl::Input]
45
- def initialize(agent, options = {})
46
- @agent = agent
47
- @provider = agent.llm.name
59
+ def initialize(repl, options = {})
60
+ @name = repl.name
61
+ @agent = repl.agent
62
+ @provider = @agent.llm.name
48
63
  @buffer = +""
49
64
  @cursor = 0
50
65
  @scroll = 0
51
66
  @height = options.fetch(:height, 3)
52
67
  @last_char_at = nil
68
+ @memory = @agent.messages.select(&:user?).map(&:content)
69
+ @walker = Walker.new(@memory)
53
70
  @paste = false
54
71
  end
55
72
 
@@ -59,8 +76,28 @@ class LLM::Repl
59
76
  # @return [Symbol, nil]
60
77
  def on_char(window, char, now)
61
78
  is_paste = lambda { @last_char_at and (now - @last_char_at) < PASTE_THRESHOLD }
62
- if ESC == char
79
+ if char and @char != char
80
+ REPEATS[@char] = 0
81
+ end
82
+ if PGUP == char
83
+ (window.rows - 3).times { window.scroll_up }
84
+ :pageup
85
+ elsif PGDOWN == char
86
+ (window.rows - 3).times { window.scroll_down }
87
+ :pagedown
88
+ elsif TAB == char
89
+ autocomplete
90
+ :tab
91
+ elsif ESC == char
63
92
  @agent.cancel!
93
+ elsif CTRL[:P] == char
94
+ @buffer = @walker.prev.dup
95
+ @cursor = @buffer.size
96
+ :ctrl_p
97
+ elsif CTRL[:N] == char
98
+ @buffer = @walker.next.dup
99
+ @cursor = @buffer.size
100
+ :ctrl_n
64
101
  elsif CTRL[:D] == char
65
102
  delete
66
103
  :ctrl_d
@@ -85,14 +122,16 @@ class LLM::Repl
85
122
  elsif char == RIGHT
86
123
  move_right
87
124
  :right
88
- elsif BACKSPACE.include?(char)
125
+ elsif BACKSPACE == char
89
126
  backspace
90
127
  :backspace
91
- elsif ENTER.include?(char)
128
+ elsif ENTER == char
92
129
  if @paste = is_paste.()
93
130
  insert("\n")
94
131
  :char
95
132
  else
133
+ @memory.push(@buffer.dup)
134
+ @walker.cursor = @memory.size
96
135
  :submit
97
136
  end
98
137
  elsif char == UP
@@ -108,19 +147,23 @@ class LLM::Repl
108
147
  nil
109
148
  end
110
149
  ensure
111
- @last_char_at = now if char
150
+ if char
151
+ REPEATS[char] += 1
152
+ @last_char_at = now
153
+ @char = char
154
+ end
112
155
  end
113
156
 
114
157
  ##
115
158
  # @return [String]
116
159
  def to_s
117
- "#{@provider}> #{@buffer}"
160
+ "#{prompt}#{@buffer}"
118
161
  end
119
162
 
120
163
  ##
121
164
  # @return [Integer]
122
165
  def cursor
123
- prompt.length + @cursor
166
+ prompt.size + @cursor
124
167
  end
125
168
 
126
169
  ##
@@ -182,6 +225,28 @@ class LLM::Repl
182
225
  @cursor = [0, @cursor + 1].max
183
226
  end
184
227
 
228
+ ##
229
+ # @return [void]
230
+ def autocomplete
231
+ return unless @buffer[0] == "/"
232
+ ##
233
+ # This method implements a simple autocomplete
234
+ # that supports cycling through all known
235
+ # commands. When given tab in quick succession,
236
+ # we cycle to the nearest neighbour for the last
237
+ # full match. However, it's not based on similarity,
238
+ # it's just the next element in the array.
239
+ keys = LLM::Command.registry.keys
240
+ candidates = LLM::Command.complete(@buffer)
241
+ if REPEATS[TAB] >= 1
242
+ candidate = keys[keys.index(candidates[0]) + 1] || keys[0]
243
+ else
244
+ candidate = candidates[0]
245
+ end
246
+ @buffer = "/#{candidate}"
247
+ @cursor = @buffer.size
248
+ end
249
+
185
250
  ##
186
251
  # @return [void]
187
252
  def kill
@@ -238,7 +303,7 @@ class LLM::Repl
238
303
  end
239
304
 
240
305
  def prompt
241
- "#{@provider}> "
306
+ "#{@provider}(#{@name})> "
242
307
  end
243
308
 
244
309
  def insert(char)
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl::Markdown
4
+ ##
5
+ # Renders Kramdown `:table` nodes as aligned columns.
6
+ module Table
7
+ ##
8
+ # Renders a table node by collecting all cells first to
9
+ # compute column widths, then emitting each row with
10
+ # padded text.
11
+ def walk_table(node, attrs)
12
+ rows = collect_rows(node, attrs)
13
+ return if rows.empty?
14
+ widths = column_widths(rows)
15
+ rows.each do |row|
16
+ emit("| ", attrs)
17
+ row.each_with_index do |chunks, i|
18
+ text = chunks.map { _1[:text] }.join
19
+ width = widths[i]
20
+ chunks.each { |c| emit(c[:text].ljust(width), c[:attrs]) }
21
+ emit(" | ", attrs) unless i == row.size - 1
22
+ end
23
+ emit(" |", attrs)
24
+ emit("\n", attrs)
25
+ end
26
+ emit("\n", attrs)
27
+ end
28
+
29
+ private
30
+
31
+ def collect_rows(node, attrs)
32
+ node.children.each_with_object([]) do |section, rows|
33
+ next unless [:thead, :tbody].include?(section.type)
34
+ section.children.each do |tr|
35
+ next unless tr.type == :tr
36
+ cells = tr.children.filter_map do |td|
37
+ next unless [:td, :th].include?(td.type)
38
+ collect_chunks(td, attrs)
39
+ end
40
+ rows << cells
41
+ end
42
+ end
43
+ end
44
+
45
+ def collect_chunks(node, attrs)
46
+ [].tap do |chunks|
47
+ walk_collect(node, attrs, chunks)
48
+ end
49
+ end
50
+
51
+ def walk_collect(node, attrs, chunks)
52
+ case node.type
53
+ when :text
54
+ chunks << {text: node.value.to_s, attrs:}
55
+ when :strong
56
+ node.children.each { walk_collect(_1, Curses::A_BOLD, chunks) }
57
+ when :em
58
+ node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
59
+ when :codespan
60
+ chunks << {text: node.value, attrs: Curses::A_REVERSE}
61
+ when :a
62
+ node.children.each { walk_collect(_1, Curses::A_UNDERLINE, chunks) }
63
+ else
64
+ node.children.each { walk_collect(_1, attrs, chunks) }
65
+ end
66
+ end
67
+
68
+ def column_widths(rows)
69
+ return [] if rows.empty?
70
+ cols = rows.first.size
71
+ (0...cols).map do |i|
72
+ rows.map { |r| r[i].map { _1[:text] }.join.length }.max
73
+ end
74
+ end
75
+ end
76
+ end
@@ -8,11 +8,16 @@ class LLM::Repl
8
8
  # of text, and also optional style information for
9
9
  # that text (eg bold, underscore, ...)
10
10
  class Markdown
11
+ require_relative "markdown/table"
12
+ include Table
13
+
11
14
  ##
12
15
  # @param [String] text
16
+ # @param [Integer] width
13
17
  # @return [LLM::Repl::Markdown]
14
- def initialize(text)
18
+ def initialize(text, width)
15
19
  @doc = Kramdown::Document.new(text)
20
+ @width = width
16
21
  @ast = []
17
22
  end
18
23
 
@@ -73,6 +78,31 @@ class LLM::Repl
73
78
  emit("\n\n", attrs)
74
79
  when :br
75
80
  emit("\n", attrs)
81
+ when :ul, :ol
82
+ node.children.each { walk(_1, attrs) }
83
+ when :li
84
+ node.children.each { walk(_1, attrs) }
85
+ when :table
86
+ walk_table(node, attrs)
87
+ when :thead, :tbody
88
+ node.children.each { walk(_1, attrs) }
89
+ when :tr
90
+ emit("| ", attrs)
91
+ node.children.each { walk(_1, attrs) }
92
+ emit("\n", attrs)
93
+ when :td, :th
94
+ node.children.each { walk(_1, attrs) }
95
+ emit(" | ", attrs)
96
+ when :blockquote
97
+ emit("> ", attrs)
98
+ node.children.each { walk(_1, attrs) }
99
+ when :hr
100
+ emit("─" * @width, attrs)
101
+ emit("\n\n", attrs)
102
+ when :a
103
+ node.children.each { walk(_1, Curses::A_UNDERLINE) }
104
+ when :img
105
+ emit("[image: #{node.attr['alt']}]", attrs)
76
106
  else
77
107
  node.children.each { walk(_1, attrs) }
78
108
  end
@@ -7,7 +7,7 @@ class LLM::Repl
7
7
  # @api private
8
8
  class Status
9
9
  ##
10
- # @param [String, Symbol] provider
10
+ # @param [LLM::Agent] agent
11
11
  # @return [LLM::Repl::Status]
12
12
  def initialize(agent)
13
13
  @agent = agent
@@ -60,9 +60,16 @@ class LLM::Repl
60
60
  # @param [Integer] max
61
61
  # @return [String]
62
62
  def format_args(tool, max: 50)
63
- args = tool.arguments
64
- pairs = args.to_h.map { "#{_1}: #{format_value(_2)}" }
65
- result = pairs.join(", ")
63
+ ##
64
+ # 'tool.arguments' might be returned
65
+ # (by the model) in a different order
66
+ # than the tool definition - this code
67
+ # handles re-sorting.
68
+ args = tool.arguments
69
+ props = tool.params.properties.keys
70
+ props = props.sort_by { tool.params.properties[_1].index }
71
+ props = props.filter_map { args[_1] ? "#{_1}: #{format_value(args[_1])}" : nil }
72
+ result = props.join(", ")
66
73
  result.size > max ? "#{result[0...max - 1]}…" : result
67
74
  end
68
75
 
@@ -42,7 +42,7 @@ class LLM::Repl
42
42
  # @param [Symbol] method
43
43
  # @return [void]
44
44
  def markdown(chars, method: :append)
45
- chunks = LLM::Repl::Markdown.new(chars).ast
45
+ chunks = LLM::Repl::Markdown.new(chars, WIDTH).ast
46
46
  self.method(method).call(chunks)
47
47
  end
48
48
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Repl
4
+ ##
5
+ # Walks through an array from the last element backward,
6
+ # one step at a time. {#next} moves forward toward the end.
7
+ # Both methods clamp at the array boundaries.
8
+ class Walker
9
+ ##
10
+ # Sets the cursor position
11
+ attr_writer :cursor
12
+
13
+ ##
14
+ # @param [Array] items
15
+ def initialize(items)
16
+ @items = items
17
+ @cursor = items.size
18
+ end
19
+
20
+ ##
21
+ # @return [Object, nil]
22
+ def next
23
+ if @items.empty?
24
+ nil
25
+ elsif @cursor >= @items.size - 1
26
+ @items[@cursor]
27
+ else
28
+ @cursor += 1
29
+ @items[@cursor]
30
+ end
31
+ end
32
+
33
+ ##
34
+ # @return [Object, nil]
35
+ def prev
36
+ if @items.empty?
37
+ nil
38
+ elsif @cursor <= 0
39
+ @items[@cursor]
40
+ else
41
+ @cursor -= 1
42
+ @items[@cursor]
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/llm/repl.rb CHANGED
@@ -23,9 +23,17 @@ module LLM
23
23
  require_relative "repl/stream"
24
24
  require_relative "repl/markdown"
25
25
  require_relative "repl/command"
26
+ require_relative "repl/walker"
27
+
28
+ attr_reader :agent, :provider, :stream,
29
+ :status, :transcript, :input,
30
+ :window, :tools, :thread,
31
+ :name, :path
26
32
 
27
33
  ##
28
34
  # @param [LLM::Agent] agent
35
+ # @param [String, nil] name
36
+ # The agent's name (optional)
29
37
  # @param [String, nil] path
30
38
  # The path where to maintain runtime state
31
39
  # @param [Array<LLM::Tool>] tools
@@ -33,13 +41,14 @@ module LLM
33
41
  # @param [Array<String>] skills
34
42
  # Zero or more skills
35
43
  # @return [LLM::Repl]
36
- def initialize(agent:, tools: [], skills: [], path: nil)
37
- @path = path
44
+ def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
45
+ @path = path
46
+ @name = name || "agent"
38
47
  @agent = configure(agent:, path:)
39
48
  @provider = agent.llm.name
40
49
  @status = Status.new(@agent)
41
50
  @transcript = Transcript.new
42
- @input = Input.new(@agent, height: 3)
51
+ @input = Input.new(self, height: 3)
43
52
  @window = Window.new(@status, @transcript, @input)
44
53
  @thread = nil
45
54
  @queue = Queue.new
@@ -150,7 +159,7 @@ module LLM
150
159
  status.text = thinking_text
151
160
  write("user: ", Curses::A_BOLD)
152
161
  markdown(text)
153
- write("\nagent: ", Curses::A_BOLD)
162
+ write("\n#{name}: ", Curses::A_BOLD)
154
163
  @thread = Thread.new do
155
164
  @queue << [:start]
156
165
  agent.talk(text, tools:, stream:)
@@ -181,12 +190,14 @@ module LLM
181
190
  "command(#{command.name}): ",
182
191
  "too few arguments: expected #{reqc} but got #{args.size}\n\n"]
183
192
  else
193
+ parameters = parameters.sort_by(&:index).to_h
194
+ kwargs = {}
184
195
  parameters.each_value do |parameter|
185
196
  if args[parameter.index]
186
- parameter.value = args[parameter.index]
197
+ kwargs[parameter.name] = args[parameter.index]
187
198
  end
188
199
  end
189
- [:command, command.new(self), parameters.transform_values(&:value)]
200
+ [:command, command.new(self), kwargs]
190
201
  end
191
202
  else
192
203
  [:input, text]
@@ -216,7 +227,7 @@ module LLM
216
227
  @thread = nil
217
228
  when :cancel
218
229
  status.text = "Idle"
219
- write("\n\nagent: ", Curses::A_BOLD)
230
+ write("\n\n#{name}: ", Curses::A_BOLD)
220
231
  write("request cancelled!")
221
232
  write("\n\n")
222
233
  transcript.finish
@@ -246,11 +257,6 @@ module LLM
246
257
  File.readable?(path)
247
258
  end
248
259
 
249
- attr_reader :agent, :provider, :stream,
250
- :status, :transcript, :input,
251
- :window, :tools, :thread,
252
- :path
253
-
254
260
  File = ::File
255
261
  private_constant :File
256
262
  end
data/lib/llm/response.rb CHANGED
@@ -10,12 +10,22 @@ module LLM
10
10
  # handling can share one common surface without flattening away
11
11
  # specialized behavior.
12
12
  #
13
+ # @example Accessing response data
14
+ # res = agent.talk "Weather in Paris?", schema: Weather
15
+ # res.content # => raw text or structured data
16
+ # res.content! # => parsed JSON as LLM::Object
17
+ # res.usage # => token usage (input, output, cache, etc.)
18
+ # res.messages # => message history from this turn
19
+ #
13
20
  # The normalized response keeps the transport response available
14
21
  # through {#res}. When the default net/http transport is in use,
15
22
  # {LLM::Transport::Response::HTTP
16
23
  # LLM::Transport::Response::HTTP} keeps the
17
24
  # original `Net::HTTPResponse` available through
18
25
  # its own {LLM::Transport::Response::HTTP#res #res}.
26
+ #
27
+ # @see LLM::Context Responses are returned by Context#talk
28
+ # @see LLM::Agent Responses are returned by Agent#talk
19
29
  class Response
20
30
  require "json"
21
31
 
@@ -8,12 +8,17 @@ class LLM::Schema
8
8
  # an example of how to create instances of {LLM::Schema::Leaf LLM::Schema::Leaf}
9
9
  # through its subclasses.
10
10
  class Leaf
11
+ ##
12
+ # @return [Integer, nil]
13
+ attr_accessor :index
14
+
11
15
  def initialize
12
16
  @description = nil
13
17
  @default = nil
14
18
  @enum = nil
15
19
  @required = nil
16
20
  @const = nil
21
+ @index = nil
17
22
  end
18
23
 
19
24
  ##
@@ -16,21 +16,22 @@ class LLM::Schema
16
16
  # A hash of properties
17
17
  # @return [LLM::Schema::Object]
18
18
  def initialize(properties)
19
- @properties = properties
19
+ @properties = LLM::Object.new
20
+ properties.each { set!(_1, _2) }
20
21
  end
21
22
 
22
23
  ##
23
24
  # Get a property
24
25
  # @return [LLM::Schema::Leaf]
25
26
  def [](key)
26
- properties[key.to_s]
27
+ properties[key]
27
28
  end
28
29
 
29
30
  ##
30
31
  # Set a property
31
32
  # @return [void]
32
33
  def []=(key, val)
33
- properties[key.to_s] = val
34
+ set!(key, val)
34
35
  end
35
36
 
36
37
  ##
@@ -46,7 +47,7 @@ class LLM::Schema
46
47
  # Returns self
47
48
  def merge!(other)
48
49
  raise TypeError, "expected #{self.class} but got #{other.class}" unless self.class === other
49
- @properties.merge!(other.properties)
50
+ other.properties.each { |key, val| self[key] = val }
50
51
  self
51
52
  end
52
53
 
@@ -64,8 +65,13 @@ class LLM::Schema
64
65
 
65
66
  private
66
67
 
68
+ def set!(key, val)
69
+ val.index = @properties.size
70
+ properties[key] = val
71
+ end
72
+
67
73
  def required_items
68
- @properties.filter_map { _2.required? ? _1 : nil }
74
+ @properties.filter_map { _2.required? ? _1 : nil }
69
75
  end
70
76
  end
71
77
  end
@@ -32,9 +32,41 @@ module LLM::Sequel
32
32
  @llm_agent_options || Agent::DEFAULTS
33
33
  end
34
34
 
35
+ ##
36
+ # @return [Class<LLM::Agent>]
35
37
  def agent
36
38
  @agent ||= Class.new(LLM::Agent)
37
39
  end
40
+
41
+ ##
42
+ # Bulk-assign class-level agent defaults.
43
+ #
44
+ # Each key is resolved by calling the corresponding class method on the
45
+ # internal agent subclass.
46
+ #
47
+ # @example
48
+ # class Agent < Sequel::Model
49
+ # plugin :llm_agent
50
+ # set instructions: "You are a system administrator",
51
+ # model: "gpt-4.1-nano",
52
+ # tools: [Shell]
53
+ # end
54
+ #
55
+ # @param [Hash] properties
56
+ # @option properties [String] :instructions
57
+ # @option properties [String] :model
58
+ # @option properties [Array<LLM::Function>] :tools
59
+ # @option properties [Array<String>] :skills
60
+ # @option properties [#to_json] :schema
61
+ # @option properties [Symbol, Array<Symbol>] :concurrency
62
+ # @option properties [LLM::Tracer, Proc] :tracer
63
+ # @option properties [Object, Proc] :stream
64
+ # @option properties [String, Symbol, Array<String, Symbol>, Proc] :confirm
65
+ # @raise [KeyError] when a property key does not match a class-level accessor
66
+ # @return [void]
67
+ def set(properties)
68
+ agent.set(properties)
69
+ end
38
70
  end
39
71
 
40
72
  module InstanceMethods
@@ -204,17 +204,17 @@ module LLM::Sequel
204
204
  end
205
205
 
206
206
  ##
207
- # @see LLM::Context#functions
207
+ # @see LLM::Context#pending_functions
208
208
  # @return [Array<LLM::Function>]
209
- def functions
210
- ctx.functions
209
+ def pending_functions
210
+ ctx.pending_functions
211
211
  end
212
212
 
213
213
  ##
214
- # @see LLM::Context#functions?
214
+ # @see LLM::Context#pending_functions?
215
215
  # @return [Boolean]
216
- def functions?
217
- ctx.functions?
216
+ def pending_functions?
217
+ ctx.pending_functions?
218
218
  end
219
219
 
220
220
  ##