llm.rb 13.0.0 → 14.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +505 -14
  3. data/README.md +484 -50
  4. data/bin/llm.rb +148 -0
  5. data/data/anthropic.json +206 -263
  6. data/data/bedrock.json +2138 -1860
  7. data/data/deepinfra.json +1003 -624
  8. data/data/deepseek.json +38 -34
  9. data/data/google.json +1079 -371
  10. data/data/mistral.json +448 -368
  11. data/data/moonshot.json +384 -0
  12. data/data/openai.json +974 -1343
  13. data/data/xai.json +154 -126
  14. data/data/zai.json +191 -191
  15. data/lib/llm/agent.rb +123 -20
  16. data/lib/llm/context.rb +71 -88
  17. data/lib/llm/cost.rb +23 -17
  18. data/lib/llm/error.rb +0 -8
  19. data/lib/llm/function/array.rb +3 -3
  20. data/lib/llm/function/async/task.rb +2 -0
  21. data/lib/llm/function/fiber/task.rb +2 -0
  22. data/lib/llm/function/fork/task.rb +2 -0
  23. data/lib/llm/function/ractor/task.rb +2 -0
  24. data/lib/llm/function/sequential/group.rb +4 -1
  25. data/lib/llm/function/sequential/task.rb +1 -1
  26. data/lib/llm/function/task.rb +4 -0
  27. data/lib/llm/function/thread/task.rb +2 -0
  28. data/lib/llm/function.rb +33 -6
  29. data/lib/llm/guard/loop.rb +89 -0
  30. data/lib/llm/guard/null.rb +19 -0
  31. data/lib/llm/guard.rb +61 -0
  32. data/lib/llm/provider.rb +36 -0
  33. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  34. data/lib/llm/providers/anthropic.rb +2 -9
  35. data/lib/llm/providers/bedrock/request_adapter.rb +1 -1
  36. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  37. data/lib/llm/providers/bedrock.rb +1 -8
  38. data/lib/llm/providers/google/stream_parser.rb +1 -0
  39. data/lib/llm/providers/google.rb +1 -8
  40. data/lib/llm/providers/mistral.rb +1 -1
  41. data/lib/llm/providers/moonshot.rb +76 -0
  42. data/lib/llm/providers/ollama.rb +2 -9
  43. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  44. data/lib/llm/providers/openai/responses.rb +7 -9
  45. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  46. data/lib/llm/providers/openai.rb +4 -11
  47. data/lib/llm/repl/bar.rb +4 -3
  48. data/lib/llm/repl/{transcript.rb → buffer.rb} +69 -29
  49. data/lib/llm/repl/color.rb +78 -0
  50. data/lib/llm/repl/command.rb +12 -5
  51. data/lib/llm/repl/commands/compact.rb +2 -2
  52. data/lib/llm/repl/commands/help.rb +3 -5
  53. data/lib/llm/repl/input/char.rb +46 -0
  54. data/lib/llm/repl/input/row.rb +39 -0
  55. data/lib/llm/repl/input.rb +251 -66
  56. data/lib/llm/repl/markdown/table.rb +11 -3
  57. data/lib/llm/repl/markdown.rb +34 -8
  58. data/lib/llm/repl/node.rb +37 -0
  59. data/lib/llm/repl/status.rb +42 -7
  60. data/lib/llm/repl/stream.rb +18 -6
  61. data/lib/llm/repl/walker.rb +3 -2
  62. data/lib/llm/repl/window.rb +54 -35
  63. data/lib/llm/repl.rb +74 -32
  64. data/lib/llm/skill.rb +20 -4
  65. data/lib/llm/stream.rb +8 -7
  66. data/lib/llm/tool.rb +29 -0
  67. data/lib/llm/tools/{swap_text.rb → edit-file.rb} +3 -3
  68. data/lib/llm/tools/git.rb +3 -0
  69. data/lib/llm/tools/mkdir.rb +3 -0
  70. data/lib/llm/tools/rg.rb +3 -0
  71. data/lib/llm/tools/ruby.rb +46 -0
  72. data/lib/llm/tools/shell.rb +3 -0
  73. data/lib/llm/tracer/pretty_logger.rb +127 -0
  74. data/lib/llm/tracer.rb +1 -0
  75. data/lib/llm/transformer/null.rb +21 -0
  76. data/lib/llm/transformer.rb +55 -0
  77. data/lib/llm/version.rb +1 -1
  78. data/lib/llm.rb +12 -2
  79. data/llm.gemspec +9 -2
  80. data/resources/deepdive/advanced/cancellation.md +74 -0
  81. data/resources/deepdive/advanced/compaction.md +83 -0
  82. data/resources/deepdive/advanced/context.md +267 -0
  83. data/resources/deepdive/advanced/guard.md +371 -0
  84. data/resources/deepdive/advanced/tracer.md +180 -0
  85. data/resources/deepdive/advanced/transformer.md +67 -0
  86. data/resources/deepdive/advanced/transports.md +45 -0
  87. data/resources/deepdive/everything_else/audio.md +122 -0
  88. data/resources/deepdive/everything_else/cost.md +99 -0
  89. data/resources/deepdive/everything_else/images.md +89 -0
  90. data/resources/deepdive/everything_else/object.md +108 -0
  91. data/resources/deepdive/everything_else/ocr.md +48 -0
  92. data/resources/deepdive/fundamentals/agents.md +202 -0
  93. data/resources/deepdive/fundamentals/builtin_tools.md +191 -0
  94. data/resources/deepdive/fundamentals/concurrency.md +104 -0
  95. data/resources/deepdive/fundamentals/database.md +449 -0
  96. data/resources/deepdive/fundamentals/embeddings.md +157 -0
  97. data/resources/deepdive/fundamentals/repl.md +87 -0
  98. data/resources/deepdive/fundamentals/schema.md +61 -0
  99. data/resources/deepdive/fundamentals/skills.md +106 -0
  100. data/resources/deepdive/fundamentals/stream.md +110 -0
  101. data/resources/deepdive/fundamentals/tools.md +265 -0
  102. data/resources/deepdive/protocols/a2a.md +106 -0
  103. data/resources/deepdive/protocols/mcp.md +111 -0
  104. data/resources/deepdive.md +58 -1792
  105. metadata +51 -7
  106. data/lib/llm/loop_guard.rb +0 -107
@@ -4,22 +4,31 @@ class LLM::Repl
4
4
  ##
5
5
  # The {LLM::Repl::Status LLM::Repl::Status} class stores
6
6
  # the small status line shown at the top of the REPL.
7
+ #
8
+ # The status text can be a plain String, a single
9
+ # {LLM::Repl::Node LLM::Repl::Node} (text plus curses attributes),
10
+ # or an Array of either. Attributes on nodes are applied when the
11
+ # window draws the status line.
7
12
  # @api private
8
13
  class Status
9
14
  ##
10
- # @param [LLM::Agent] agent
15
+ # @param [LLM::Repl] repl
11
16
  # @return [LLM::Repl::Status]
12
- def initialize(agent)
13
- @agent = agent
14
- @provider = agent.llm.name
15
- @text = "idle"
17
+ def initialize(repl)
18
+ @agent = repl.agent
19
+ @provider = @agent.llm.name
20
+ @nodes = [Node.new("idle")]
16
21
  end
17
22
 
18
23
  ##
19
24
  # @return [String]
20
25
  def context_bar
21
26
  LLM::Repl::Bar.new(
22
- used: @agent.usage.total_tokens,
27
+ ##
28
+ # After compaction the used context is unknown until the next
29
+ # response, so the bar renders an unknown state instead of a
30
+ # stale percentage.
31
+ used: @agent.compacted? ? nil : @agent.usage.total_tokens,
23
32
  total: @agent.context_window
24
33
  ).to_s
25
34
  end
@@ -30,12 +39,38 @@ class LLM::Repl
30
39
  "$#{@agent.cost}"
31
40
  end
32
41
 
42
+ ##
43
+ # Sets the status text.
44
+ # @param [String, LLM::Repl::Node, Array<String, LLM::Repl::Node>] value
45
+ # @return [Array<LLM::Repl::Node>]
46
+ def text=(value)
47
+ @nodes =
48
+ case value
49
+ when Node then [value]
50
+ when Array then value.map { |node| Node === node ? node : Node.new(node) }
51
+ else [Node.new(value)]
52
+ end
53
+ end
54
+
33
55
  ##
34
56
  # @return [String]
35
- attr_accessor :text
57
+ def text
58
+ @nodes.map(&:text).join
59
+ end
36
60
 
37
61
  ##
38
62
  # @return [String]
39
63
  alias_method :to_s, :text
64
+
65
+ ##
66
+ # The nodes making up the status line.
67
+ # @return [Array<LLM::Repl::Node>]
68
+ def nodes
69
+ if @agent.compacted?
70
+ [Node.new("Context compacted")]
71
+ else
72
+ @nodes
73
+ end
74
+ end
40
75
  end
41
76
  end
@@ -8,6 +8,10 @@ class LLM::Repl
8
8
  # tool returns.
9
9
  # @api private
10
10
  class Stream < LLM::Stream
11
+ ##
12
+ # @return [Hash<Symbol, LLM::Tool>]
13
+ attr_reader :tools
14
+
11
15
  ##
12
16
  # @param [LLM::Repl] repl
13
17
  # @return [LLM::Repl::Stream]
@@ -15,6 +19,7 @@ class LLM::Repl
15
19
  @repl = repl
16
20
  @_queue = queue
17
21
  @buffer = +""
22
+ @tools = {}
18
23
  end
19
24
 
20
25
  ##
@@ -30,26 +35,33 @@ class LLM::Repl
30
35
  # @param [LLM::Function] tool
31
36
  # @return [void]
32
37
  def on_tool_call(tool)
33
- @_queue.push [:status, "#{tool.name}(#{format_args(tool)})"]
38
+ @tools[tool.name] = tool
39
+ @_queue.push [:status, [" ", lambda, " • #{tool.name}(#{format_args(tool)})"]]
34
40
  end
35
41
 
36
42
  ##
37
- # @param [LLM::Function] _tool
43
+ # @param [LLM::Function] tool
38
44
  # @param [LLM::Function::Return] result
39
45
  # @return [void]
40
- def on_tool_return(_tool, result)
41
- @_queue.push [:status, @repl.thinking_text]
46
+ def on_tool_return(tool, result)
47
+ @tools.delete(tool.name)
42
48
  end
43
49
 
44
50
  ##
45
- # Empty the accumulated buffer
51
+ # Clear the accumulated buffer
46
52
  # @return [void]
47
- def empty!
53
+ def clear
48
54
  @buffer.clear
49
55
  end
50
56
 
51
57
  private
52
58
 
59
+ ##
60
+ # @return [LLM::Repl::Node]
61
+ def lambda
62
+ Node.new("λ", Curses::A_BOLD | Color.red)
63
+ end
64
+
53
65
  ##
54
66
  # Formats tool arguments as compact key: value pairs
55
67
  # suitable for the status line. Strings are quoted and
@@ -18,12 +18,13 @@ class LLM::Repl
18
18
  end
19
19
 
20
20
  ##
21
- # @return [Object, nil]
21
+ # @return [Object, String, nil]
22
22
  def next
23
23
  if @items.empty?
24
24
  nil
25
25
  elsif @cursor >= @items.size - 1
26
- @items[@cursor]
26
+ @cursor = @items.size
27
+ ""
27
28
  else
28
29
  @cursor += 1
29
30
  @items[@cursor]
@@ -11,22 +11,22 @@ class LLM::Repl
11
11
  attr_reader :status
12
12
 
13
13
  ##
14
- # @return [LLM::Repl::Transcript]
15
- attr_reader :transcript
14
+ # @return [LLM::Repl::Buffer]
15
+ attr_reader :buffer
16
16
 
17
17
  ##
18
18
  # @return [LLM::Repl::Input]
19
19
  attr_reader :input
20
20
 
21
21
  ##
22
- # @param [LLM::Repl::Status] status
23
- # @param [LLM::Repl::Transcript] transcript
24
- # @param [LLM::Repl::Input] input
22
+ # @param [LLM::Repl] repl
23
+ # A read-eval-print loop.
25
24
  # @return [LLM::Repl::Window]
26
- def initialize(status, transcript, input)
27
- @status = status
28
- @transcript = transcript
29
- @input = input
25
+ def initialize(repl)
26
+ @repl = repl
27
+ @status = repl.status
28
+ @buffer = repl.buffer
29
+ @input = repl.input
30
30
  end
31
31
 
32
32
  ##
@@ -34,6 +34,7 @@ class LLM::Repl
34
34
  # @return [void]
35
35
  def open
36
36
  Curses.init_screen
37
+ Color.enable
37
38
  Curses.cbreak
38
39
  Curses.noecho
39
40
  Curses.stdscr.keypad(true)
@@ -48,7 +49,7 @@ class LLM::Repl
48
49
  def redraw
49
50
  draw_status(offset: input.height + 1)
50
51
  draw_divider(offset: 5)
51
- draw_transcript(offset: 0)
52
+ draw_buffer(offset: 0)
52
53
  draw_input
53
54
  Curses.refresh
54
55
  end
@@ -59,6 +60,12 @@ class LLM::Repl
59
60
  [Curses.lines - (input.height + 4), 1].max
60
61
  end
61
62
 
63
+ ##
64
+ # @return [Integer]
65
+ def columns
66
+ Curses.cols
67
+ end
68
+
62
69
  ##
63
70
  # @return [Object]
64
71
  def getch
@@ -86,27 +93,56 @@ class LLM::Repl
86
93
  ##
87
94
  # @return [void]
88
95
  def scroll_up
89
- transcript.scroll_up(rows)
96
+ buffer.scroll_up(rows)
90
97
  end
91
98
 
92
99
  ##
93
100
  # @return [void]
94
101
  def scroll_down
95
- transcript.scroll_down
102
+ buffer.scroll_down
96
103
  end
97
104
 
98
105
  ##
99
106
  # @return [void]
100
107
  def scroll_to_bottom
101
- transcript.scroll_to_bottom
108
+ buffer.scroll_to_bottom
102
109
  end
103
110
 
104
111
  private
105
112
 
113
+ def draw_buffer(offset:)
114
+ rows = buffer.visible(self.rows)
115
+ rows.each.with_index(offset) do |row, index|
116
+ Curses.setpos(index, 0)
117
+ Curses.clrtoeol
118
+ Curses.setpos(index, gutter)
119
+ width = 0
120
+ row.each do |chunk|
121
+ remaining = buffer.width - width
122
+ break if remaining <= 0
123
+ text, attrs = chunk.text.to_s, chunk.attrs
124
+ clipped = text[0, remaining]
125
+ Curses.attron(attrs) if attrs
126
+ Curses.addstr(clipped)
127
+ Curses.attroff(attrs) if attrs
128
+ width += clipped.length
129
+ end
130
+ end
131
+ last_drawn = offset + rows.size
132
+ (last_drawn...self.rows).each do |line|
133
+ Curses.setpos(line, 0)
134
+ Curses.clrtoeol
135
+ end
136
+ end
137
+
106
138
  def draw_status(offset:)
107
139
  Curses.setpos(Curses.lines - offset, 0)
108
140
  Curses.clrtoeol
109
- Curses.addstr(status.to_s)
141
+ status.nodes.each do |node|
142
+ Curses.attron(node.attrs) if node.attrs
143
+ Curses.addstr(node.text)
144
+ Curses.attroff(node.attrs) if node.attrs
145
+ end
110
146
  context = status.context_bar
111
147
  Curses.setpos(Curses.lines - offset, [(columns - context.length) / 2, 0].max)
112
148
  Curses.addstr(context)
@@ -132,29 +168,12 @@ class LLM::Repl
132
168
  Curses.setpos((Curses.lines - input.height) + line, col)
133
169
  end
134
170
 
135
- def draw_transcript(offset:)
136
- rows = transcript.visible(self.rows)
137
- rows.each.with_index(offset) do |row, index|
138
- Curses.setpos(index, 0)
139
- Curses.clrtoeol
140
- row.each do |chunk|
141
- text, attrs = chunk.values_at(:text, :attrs)
142
- Curses.attron(attrs) if attrs
143
- Curses.addstr(text)
144
- Curses.attroff(attrs) if attrs
145
- end
146
- end
147
- last_drawn = offset + rows.size
148
- (last_drawn...self.rows).each do |line|
149
- Curses.setpos(line, 0)
150
- Curses.clrtoeol
151
- end
152
- end
153
-
154
171
  ##
172
+ # 20% offset that occupies the left margin and
173
+ # helps center {LLM::Repl::Buffer LLM::Repl::Buffer}.
155
174
  # @return [Integer]
156
- def columns
157
- Curses.cols
175
+ def gutter
176
+ (columns * 0.2).floor
158
177
  end
159
178
  end
160
179
  end
data/lib/llm/repl.rb CHANGED
@@ -15,18 +15,20 @@ module LLM
15
15
  # task correctly, or for it to correct course after a
16
16
  # mistake was made.
17
17
  class Repl
18
+ require_relative "repl/color"
18
19
  require_relative "repl/window"
19
20
  require_relative "repl/status"
20
- require_relative "repl/transcript"
21
+ require_relative "repl/buffer"
21
22
  require_relative "repl/input"
22
23
  require_relative "repl/bar"
23
24
  require_relative "repl/stream"
25
+ require_relative "repl/node"
24
26
  require_relative "repl/markdown"
25
27
  require_relative "repl/command"
26
28
  require_relative "repl/walker"
27
29
 
28
30
  attr_reader :agent, :provider, :stream,
29
- :status, :transcript, :input,
31
+ :status, :buffer, :input,
30
32
  :window, :tools, :thread,
31
33
  :name, :path
32
34
 
@@ -44,12 +46,13 @@ module LLM
44
46
  def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
45
47
  @path = path
46
48
  @name = name || "agent"
49
+ @sender = "You"
47
50
  @agent = configure(agent:, path:)
48
51
  @provider = agent.llm.name
49
- @status = Status.new(@agent)
50
- @transcript = Transcript.new
51
52
  @input = Input.new(self, height: 3)
52
- @window = Window.new(@status, @transcript, @input)
53
+ @buffer = Buffer.new(self)
54
+ @status = Status.new(self)
55
+ @window = Window.new(self)
53
56
  @thread = nil
54
57
  @queue = Queue.new
55
58
  @stream = Stream.new(self, @queue)
@@ -70,6 +73,7 @@ module LLM
70
73
  def start
71
74
  window.open do
72
75
  catch(:exit) do
76
+ write tree(agent.messages)
73
77
  loop do
74
78
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
75
79
  case input.on_char(window, input.paste? ? window.read_paste : window.getch, now)
@@ -86,22 +90,32 @@ module LLM
86
90
  end
87
91
 
88
92
  ##
89
- # @param [String] chars
93
+ # @param [String, Array] chars
90
94
  # @param [Object] attrs
95
+ # @param [Symbol] method (:append, :replace)
91
96
  # @return [void]
92
- def write(chars, attrs = nil)
93
- transcript.write(chars, attrs)
97
+ def write(chars, attrs = nil, method: :append)
98
+ buffer.write(chars, attrs, method:)
94
99
  window.redraw
95
100
  end
96
101
 
97
102
  ##
98
- # @param [String] chars
103
+ # @param [String] user
104
+ # @param [String] content
99
105
  # @return [void]
100
- def markdown(chars)
101
- transcript.markdown(chars)
106
+ def write_message(user, content, method: :append)
107
+ buffer.write_message(user, content, method:)
102
108
  window.redraw
103
109
  end
104
110
 
111
+ ##
112
+ # Returns an AST
113
+ # @param [String] chars
114
+ # @return [Array<Node>]
115
+ def markdown(chars)
116
+ LLM::Repl::Markdown.new(chars, buffer.width).ast
117
+ end
118
+
105
119
  ##
106
120
  # @param [String] value
107
121
  # @return [void]
@@ -112,12 +126,41 @@ module LLM
112
126
 
113
127
  ##
114
128
  # @return [String]
115
- def thinking_text
116
- "thinking • Esc to cancel"
129
+ def think_text
130
+ " 🧠 • Thinking • Esc to cancel"
131
+ end
132
+
133
+ ##
134
+ # @return [String]
135
+ def connect_text
136
+ " 🌐 • Connecting • Esc to cancel"
137
+ end
138
+
139
+ ##
140
+ # Returns a label for the person who sent a message.
141
+ # @return [String]
142
+ def sender
143
+ @sender
117
144
  end
118
145
 
119
146
  private
120
147
 
148
+ ##
149
+ # @param [LLM::Buffer] messages
150
+ # A message buffer
151
+ # @return [Array<Node>]
152
+ def tree(messages)
153
+ messages.flat_map do |message|
154
+ next if message.tool_call? || message.tool_return?
155
+ user = message.assistant? ? name : sender
156
+ [
157
+ Node.new("#{user}:\n", Curses::A_BOLD | Color.blue),
158
+ *markdown(message.content),
159
+ Node.new("\n\n")
160
+ ]
161
+ end.compact
162
+ end
163
+
121
164
  ##
122
165
  # @param [LLM::Agent] agent
123
166
  # An agent
@@ -151,15 +194,14 @@ module LLM
151
194
  case on_text(text)
152
195
  in [:command, Command => command, Hash => parameters]
153
196
  command.call(**parameters)
154
- in [:error, String => who, String => text]
155
- write(who, Curses::A_BOLD)
156
- write(text)
197
+ write("\n")
198
+ in [:error, String => user, String => text]
199
+ write_message(user, text)
200
+ write("\n")
157
201
  in [:input, String => text]
158
202
  window.scroll_to_bottom
159
- status.text = thinking_text
160
- write("user: ", Curses::A_BOLD)
161
- markdown(text)
162
- write("\n#{name}: ", Curses::A_BOLD)
203
+ status.text = connect_text
204
+ write_message(sender, markdown(text))
163
205
  @thread = Thread.new do
164
206
  @queue << [:start]
165
207
  agent.talk(text, tools:, stream:)
@@ -214,29 +256,29 @@ module LLM
214
256
  type, value = @queue.pop(true)
215
257
  case type
216
258
  when :start
217
- transcript.start
218
- stream.empty!
259
+ buffer.open
260
+ stream.clear
219
261
  when :stream
220
- transcript.markdown(value, method: :replace)
262
+ status.text = think_text if stream.tools.empty?
263
+ write_message name, markdown(value), method: :replace
221
264
  when :status
222
265
  self.status = value
223
266
  when :done
224
267
  status.text = "idle"
225
- write("\n\n")
226
- transcript.finish
268
+ buffer.close
227
269
  @thread = nil
270
+ write("\n")
228
271
  when :cancel
272
+ stream.tools.clear
273
+ buffer.close
229
274
  status.text = "Idle"
230
- write("\n\n#{name}: ", Curses::A_BOLD)
231
- write("request cancelled!")
232
- write("\n\n")
233
- transcript.finish
275
+ write_message(name, "Request cancelled")
234
276
  @thread = nil
277
+ write("\n")
235
278
  when :error
236
- # Do this better
237
279
  status.text = "error"
238
- write("\nerror: #{value.message}\n", Curses::A_BOLD)
239
- transcript.finish
280
+ write_message(name, "(#{value.class}): #{value.message}")
281
+ buffer.close
240
282
  @thread = nil
241
283
  end
242
284
  end
data/lib/llm/skill.rb CHANGED
@@ -7,6 +7,11 @@ module LLM
7
7
  # Skills can expose themselves as normal {LLM::Tool LLM::Tool} classes through
8
8
  # {#to_tool}. This keeps skills on the same execution path as local tools.
9
9
  class Skill
10
+ ##
11
+ # @api private
12
+ File = ::File
13
+ private_constant :File
14
+
10
15
  ##
11
16
  # Load a skill from a directory.
12
17
  # @param [String, Pathname] path
@@ -63,8 +68,14 @@ module LLM
63
68
  # Load and parse the skill.
64
69
  # @return [LLM::Skill]
65
70
  def load!
66
- path = ::File.join(@path, "SKILL.md")
67
- parse(::File.read(path))
71
+ if File.file?(@path)
72
+ parse(File.read(@path))
73
+ elsif File.directory?(@path)
74
+ path = File.join(@path, "SKILL.md")
75
+ parse(File.read(path))
76
+ else
77
+ raise LLM::Error, "'#{@path}' is neither a file or directory"
78
+ end
68
79
  self
69
80
  end
70
81
 
@@ -103,7 +114,8 @@ module LLM
103
114
  end
104
115
 
105
116
  ##
106
- # Returns true when a skill should inherit tools from its parent
117
+ # Returns true when a skill should inherit tools
118
+ # from its parent
107
119
  # @return [Boolean]
108
120
  def inherit_tools?
109
121
  @inherit_tools
@@ -138,7 +150,11 @@ module LLM
138
150
  def parse_tools(tools)
139
151
  case tools
140
152
  when String
141
- tools == "inherit" ? [true, []] : raise_invalid_error!(tools)
153
+ case tools
154
+ when "inherit" then [true, []]
155
+ when "all", "*" then [false, LLM::Tool.registry]
156
+ else raise_invalid_error!(tools)
157
+ end
142
158
  when Array
143
159
  [false, [*@frontmatter.tools].map { LLM::Tool.find_by_name!(_1) }]
144
160
  when NilClass
data/lib/llm/stream.rb CHANGED
@@ -128,7 +128,10 @@ module LLM
128
128
  ##
129
129
  # Called when a streamed tool call has been fully constructed.
130
130
  # A stream implementation may start tool execution here by pushing
131
- # `@queue << tool.task(:thread)` onto {#queue}.
131
+ # `@queue << tool.task(:thread).tap(&:spawn)` onto {#queue} — `spawn`
132
+ # returns nil, so `tap` keeps the task while starting it. The task
133
+ # carries the context's guard, so it is checked before the tool runs —
134
+ # a blocked call yields its guard return without executing.
132
135
  # @param [LLM::Function] tool
133
136
  # The parsed tool call.
134
137
  # @return [nil]
@@ -151,19 +154,17 @@ module LLM
151
154
 
152
155
  ##
153
156
  # Called before a context transformer rewrites a prompt.
154
- # @param [LLM::Context] ctx
155
- # @param [#call] transformer
157
+ # @param [LLM::Transformer] transformer
156
158
  # @return [nil]
157
- def on_transform(ctx, transformer)
159
+ def on_transform(transformer)
158
160
  nil
159
161
  end
160
162
 
161
163
  ##
162
164
  # Called after a context transformer finishes rewriting a prompt.
163
- # @param [LLM::Context] ctx
164
- # @param [#call] transformer
165
+ # @param [LLM::Transformer] transformer
165
166
  # @return [nil]
166
- def on_transform_finish(ctx, transformer)
167
+ def on_transform_finish(transformer)
167
168
  nil
168
169
  end
169
170
 
data/lib/llm/tool.rb CHANGED
@@ -150,6 +150,35 @@ class LLM::Tool
150
150
  end
151
151
  end
152
152
 
153
+ ##
154
+ # Assign multiple tool properties at once.
155
+ # @example
156
+ # class Tool < LLM::Tool
157
+ # set name: "my-tool",
158
+ # description: "my tool does this and that",
159
+ # parameters: [
160
+ # [:name, String , "this and that"],
161
+ # [:age , Integer, "this and that"]
162
+ # ],
163
+ # required: %i[name],
164
+ # defaults: {age: 42}
165
+ # end
166
+ # @param [Hash] properties
167
+ # @return [void]
168
+ def self.set(properties)
169
+ properties.each do
170
+ if _1.to_s == "parameters"
171
+ _2.each { |attributes| parameter(*attributes) }
172
+ else
173
+ if respond_to?(_1)
174
+ public_send(_1, _2)
175
+ else
176
+ raise KeyError, "key not found: #{_1}"
177
+ end
178
+ end
179
+ end
180
+ end
181
+
153
182
  ##
154
183
  # Returns (or sets) the tool name
155
184
  # @param [String, nil] name The tool name
@@ -2,11 +2,11 @@
2
2
 
3
3
  class LLM::Tool
4
4
  ##
5
- # The {LLM::Tool::SwapText LLM::Tool::SwapText} class
5
+ # The {LLM::Tool::EditFile LLM::Tool::EditFile} class
6
6
  # implements a tool that can substitute one piece of
7
7
  # text for another piece of text in a given file.
8
- class SwapText < self
9
- name "swap-text"
8
+ class EditFile < self
9
+ name "edit-file"
10
10
  description "Replace an exact snippet in a file"
11
11
  parameter :path, String, "Path to file"
12
12
  parameter :before, String, "Exact text to replace"
data/lib/llm/tools/git.rb CHANGED
@@ -26,6 +26,9 @@ class LLM::Tool
26
26
  command = spawn(action:, arguments:)
27
27
  wait(command:, timeout:)
28
28
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
29
+ rescue LLM::Interrupt
30
+ command.kill! if command&.running?
31
+ raise
29
32
  end
30
33
 
31
34
  private
@@ -15,6 +15,9 @@ class LLM::Tool
15
15
  def call(path:)
16
16
  command = spawn(path:)
17
17
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
18
+ rescue LLM::Interrupt
19
+ command.kill! if command&.running?
20
+ raise
18
21
  end
19
22
 
20
23
  private
data/lib/llm/tools/rg.rb CHANGED
@@ -27,6 +27,9 @@ class LLM::Tool
27
27
  command = spawn(patterns:, path:)
28
28
  wait(command:, timeout:)
29
29
  {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
30
+ rescue LLM::Interrupt
31
+ command.kill! if command&.running?
32
+ raise
30
33
  end
31
34
 
32
35
  private