llm.rb 13.1.0 → 15.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +716 -1826
  3. data/README.md +668 -253
  4. data/bin/llm.rb +156 -56
  5. data/data/alibaba.json +1999 -0
  6. data/data/anthropic.json +195 -252
  7. data/data/bedrock.json +2189 -1854
  8. data/data/deepinfra.json +1312 -728
  9. data/data/deepseek.json +42 -39
  10. data/data/google.json +1024 -404
  11. data/data/mistral.json +481 -401
  12. data/data/moonshot.json +384 -0
  13. data/data/openai.json +985 -1354
  14. data/data/xai.json +220 -115
  15. data/data/zai.json +166 -166
  16. data/docs/deepdive/advanced/cancellation.md +74 -0
  17. data/docs/deepdive/advanced/compaction.md +85 -0
  18. data/docs/deepdive/advanced/context.md +280 -0
  19. data/docs/deepdive/advanced/guard.md +371 -0
  20. data/docs/deepdive/advanced/transformer.md +67 -0
  21. data/docs/deepdive/advanced/transports.md +45 -0
  22. data/docs/deepdive/features/builtin_tools.md +191 -0
  23. data/docs/deepdive/features/concurrency.md +110 -0
  24. data/docs/deepdive/features/database.md +449 -0
  25. data/docs/deepdive/features/embeddings.md +157 -0
  26. data/docs/deepdive/features/repl.md +141 -0
  27. data/docs/deepdive/fundamentals/agents.md +253 -0
  28. data/docs/deepdive/fundamentals/providers.md +159 -0
  29. data/docs/deepdive/fundamentals/schema.md +61 -0
  30. data/docs/deepdive/fundamentals/skills.md +111 -0
  31. data/docs/deepdive/fundamentals/stream.md +143 -0
  32. data/docs/deepdive/fundamentals/tools.md +329 -0
  33. data/docs/deepdive/media/audio.md +122 -0
  34. data/docs/deepdive/media/images.md +89 -0
  35. data/docs/deepdive/media/ocr.md +48 -0
  36. data/docs/deepdive/protocols/a2a.md +106 -0
  37. data/docs/deepdive/protocols/mcp.md +111 -0
  38. data/docs/deepdive/reference/cost.md +109 -0
  39. data/docs/deepdive/reference/model_registry.md +271 -0
  40. data/docs/deepdive/reference/object.md +108 -0
  41. data/docs/deepdive/reference/tracer.md +187 -0
  42. data/{resources → docs}/deepdive.md +37 -23
  43. data/lib/llm/a2a/transport/http.rb +1 -1
  44. data/lib/llm/active_record/acts_as_llm.rb +19 -5
  45. data/lib/llm/agent.rb +107 -17
  46. data/lib/llm/context.rb +164 -134
  47. data/lib/llm/cost.rb +114 -49
  48. data/lib/llm/error.rb +7 -8
  49. data/lib/llm/function/array.rb +1 -1
  50. data/lib/llm/function/async/task.rb +2 -0
  51. data/lib/llm/function/fiber/task.rb +2 -0
  52. data/lib/llm/function/fork/task.rb +16 -1
  53. data/lib/llm/function/ractor/task.rb +2 -0
  54. data/lib/llm/function/sequential/group.rb +20 -10
  55. data/lib/llm/function/sequential/task.rb +2 -9
  56. data/lib/llm/function/task.rb +4 -0
  57. data/lib/llm/function/thread/task.rb +2 -0
  58. data/lib/llm/function.rb +33 -4
  59. data/lib/llm/guard/loop.rb +89 -0
  60. data/lib/llm/guard/null.rb +19 -0
  61. data/lib/llm/guard.rb +61 -0
  62. data/lib/llm/message.rb +5 -4
  63. data/lib/llm/provider.rb +43 -0
  64. data/lib/llm/providers/alibaba/error_handler.rb +34 -0
  65. data/lib/llm/providers/alibaba/request_adapter.rb +13 -0
  66. data/lib/llm/providers/alibaba.rb +93 -0
  67. data/lib/llm/providers/anthropic/stream_parser.rb +1 -0
  68. data/lib/llm/providers/anthropic.rb +1 -9
  69. data/lib/llm/providers/bedrock/stream_parser.rb +1 -0
  70. data/lib/llm/providers/bedrock.rb +9 -9
  71. data/lib/llm/providers/deepseek/request_adapter.rb +2 -33
  72. data/lib/llm/providers/google/stream_parser.rb +1 -0
  73. data/lib/llm/providers/google.rb +1 -9
  74. data/lib/llm/providers/moonshot.rb +76 -0
  75. data/lib/llm/providers/ollama.rb +1 -9
  76. data/lib/llm/providers/openai/responses/stream_parser.rb +1 -0
  77. data/lib/llm/providers/openai/responses.rb +6 -9
  78. data/lib/llm/providers/openai/schema.rb +37 -0
  79. data/lib/llm/providers/openai/stream_parser.rb +1 -0
  80. data/lib/llm/providers/openai.rb +4 -12
  81. data/lib/llm/registry/model.rb +186 -0
  82. data/lib/llm/registry.rb +45 -14
  83. data/lib/llm/repl/bar.rb +11 -12
  84. data/lib/llm/repl/buffer.rb +43 -16
  85. data/lib/llm/repl/color.rb +85 -0
  86. data/lib/llm/repl/command.rb +12 -0
  87. data/lib/llm/repl/commands/model.rb +39 -0
  88. data/lib/llm/repl/input/cache.rb +45 -0
  89. data/lib/llm/repl/input/char.rb +46 -0
  90. data/lib/llm/repl/input/row.rb +39 -0
  91. data/lib/llm/repl/input.rb +327 -79
  92. data/lib/llm/repl/markdown/table.rb +6 -2
  93. data/lib/llm/repl/markdown.rb +56 -6
  94. data/lib/llm/repl/node.rb +7 -0
  95. data/lib/llm/repl/status.rb +54 -5
  96. data/lib/llm/repl/stream.rb +16 -4
  97. data/lib/llm/repl/walker.rb +3 -2
  98. data/lib/llm/repl/window.rb +111 -11
  99. data/lib/llm/repl.rb +47 -21
  100. data/lib/llm/sequel/plugin.rb +19 -5
  101. data/lib/llm/skill.rb +21 -8
  102. data/lib/llm/stream.rb +35 -7
  103. data/lib/llm/tool.rb +27 -0
  104. data/lib/llm/tools/rg.rb +2 -1
  105. data/lib/llm/transformer/null.rb +21 -0
  106. data/lib/llm/transformer.rb +55 -0
  107. data/lib/llm/transport/curb.rb +23 -3
  108. data/lib/llm/usage.rb +155 -9
  109. data/lib/llm/version.rb +1 -1
  110. data/lib/llm.rb +121 -29
  111. data/llm.gemspec +16 -10
  112. metadata +100 -13
  113. data/lib/llm/loop_guard.rb +0 -107
data/lib/llm/repl/node.rb CHANGED
@@ -23,6 +23,13 @@ class LLM::Repl
23
23
  @attrs = attrs
24
24
  end
25
25
 
26
+ ##
27
+ # @return [Integer]
28
+ def size
29
+ @text.size
30
+ end
31
+ alias_method :length, :size
32
+
26
33
  ##
27
34
  # Hash-like lookup.
28
35
  # @param [Symbol] key
@@ -4,38 +4,87 @@ 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
15
  # @param [LLM::Repl] repl
11
16
  # @return [LLM::Repl::Status]
12
17
  def initialize(repl)
18
+ @repl = repl
13
19
  @agent = repl.agent
14
20
  @provider = @agent.llm.name
15
- @text = "idle"
21
+ @nodes = [Node.new("idle")]
16
22
  end
17
23
 
18
24
  ##
19
25
  # @return [String]
20
26
  def context_bar
21
27
  LLM::Repl::Bar.new(
22
- used: @agent.usage.total_tokens,
23
- total: @agent.context_window
28
+ ##
29
+ # After compaction the used context is unknown until the next
30
+ # response, so the bar renders an unknown state instead of a
31
+ # stale percentage.
32
+ fraction: @agent.compacted? ? nil : @agent.context_usage
24
33
  ).to_s
25
34
  end
26
35
 
27
36
  ##
28
37
  # @return [String]
29
38
  def cost
30
- "$#{@agent.cost}"
39
+ "$#{@agent.cost} "
40
+ end
41
+
42
+ ##
43
+ # Returns the current model in use.
44
+ # @return [LLM::Repl::Node]
45
+ def model
46
+ Node.new @repl.model.to_s, Curses::A_BOLD
47
+ end
48
+
49
+ ##
50
+ # Returns the current working directory.
51
+ # @return [LLM::Repl::Node]
52
+ def cwd
53
+ Node.new Dir.pwd.sub(Dir.home, "~"), Curses::A_BOLD
54
+ end
55
+
56
+ ##
57
+ # Sets the status text.
58
+ # @param [String, LLM::Repl::Node, Array<String, LLM::Repl::Node>] value
59
+ # @return [Array<LLM::Repl::Node>]
60
+ def text=(value)
61
+ @nodes =
62
+ case value
63
+ when Node then [value]
64
+ when Array then value.map { |node| Node === node ? node : Node.new(node) }
65
+ else [Node.new(value)]
66
+ end
31
67
  end
32
68
 
33
69
  ##
34
70
  # @return [String]
35
- attr_accessor :text
71
+ def text
72
+ @nodes.map(&:text).join
73
+ end
36
74
 
37
75
  ##
38
76
  # @return [String]
39
77
  alias_method :to_s, :text
78
+
79
+ ##
80
+ # The nodes making up the status line.
81
+ # @return [Array<LLM::Repl::Node>]
82
+ def nodes
83
+ if @agent.compacted?
84
+ [Node.new("Context compacted")]
85
+ else
86
+ @nodes
87
+ end
88
+ end
40
89
  end
41
90
  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,15 +35,16 @@ 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
  ##
@@ -50,6 +56,12 @@ class LLM::Repl
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]
@@ -6,6 +6,11 @@ class LLM::Repl
6
6
  # curses screen for the REPL.
7
7
  # @api private
8
8
  class Window
9
+ ##
10
+ # Rows reserved for the top chrome (the blue model/cwd row).
11
+ # @api private
12
+ TOP_ROWS = 1
13
+
9
14
  ##
10
15
  # @return [LLM::Repl::Status]
11
16
  attr_reader :status
@@ -23,6 +28,7 @@ class LLM::Repl
23
28
  # A read-eval-print loop.
24
29
  # @return [LLM::Repl::Window]
25
30
  def initialize(repl)
31
+ @repl = repl
26
32
  @status = repl.status
27
33
  @buffer = repl.buffer
28
34
  @input = repl.input
@@ -33,21 +39,25 @@ class LLM::Repl
33
39
  # @return [void]
34
40
  def open
35
41
  Curses.init_screen
42
+ Color.enable
36
43
  Curses.cbreak
37
44
  Curses.noecho
38
45
  Curses.stdscr.keypad(true)
39
46
  Curses.stdscr.nodelay = true
47
+ hide_cursor
40
48
  yield
41
49
  ensure
50
+ show_cursor
42
51
  Curses.close_screen
43
52
  end
44
53
 
45
54
  ##
46
55
  # @return [void]
47
56
  def redraw
57
+ draw_meta(offset: Curses.lines)
48
58
  draw_status(offset: input.height + 1)
49
59
  draw_divider(offset: 5)
50
- draw_buffer(offset: 0)
60
+ draw_buffer(offset: TOP_ROWS)
51
61
  draw_input
52
62
  Curses.refresh
53
63
  end
@@ -58,6 +68,20 @@ class LLM::Repl
58
68
  [Curses.lines - (input.height + 4), 1].max
59
69
  end
60
70
 
71
+ ##
72
+ # @return [Integer]
73
+ def columns
74
+ Curses.cols
75
+ end
76
+
77
+ ##
78
+ # Redraws the window after it has been resized.
79
+ # @return [void]
80
+ def resize
81
+ Curses.clear
82
+ redraw
83
+ end
84
+
61
85
  ##
62
86
  # @return [Object]
63
87
  def getch
@@ -102,43 +126,91 @@ class LLM::Repl
102
126
 
103
127
  private
104
128
 
129
+ ##
130
+ # Draws the window's primary content
131
+ # @return [void]
105
132
  def draw_buffer(offset:)
106
133
  rows = buffer.visible(self.rows)
107
- rows.each.with_index(offset) do |row, index|
134
+ rows.each.with_index(TOP_ROWS) do |row, index|
108
135
  Curses.setpos(index, 0)
109
136
  Curses.clrtoeol
137
+ Curses.setpos(index, gutter)
138
+ width = 0
110
139
  row.each do |chunk|
111
- text, attrs = chunk.text, chunk.attrs
140
+ remaining = buffer.width - width
141
+ break if remaining <= 0
142
+ text, attrs = chunk.text.to_s, chunk.attrs
143
+ clipped = text[0, remaining]
112
144
  Curses.attron(attrs) if attrs
113
- Curses.addstr(text)
145
+ Curses.addstr(clipped)
114
146
  Curses.attroff(attrs) if attrs
147
+ width += clipped.length
115
148
  end
116
149
  end
117
150
  last_drawn = offset + rows.size
118
- (last_drawn...self.rows).each do |line|
151
+ (last_drawn...(Curses.lines - 5)).each do |line|
119
152
  Curses.setpos(line, 0)
120
153
  Curses.clrtoeol
121
154
  end
122
155
  end
123
156
 
157
+ ##
158
+ # Draws the status line
159
+ # @return [void]
124
160
  def draw_status(offset:)
125
- Curses.setpos(Curses.lines - offset, 0)
161
+ y = Curses.lines - offset
162
+ Curses.setpos(y, 0)
126
163
  Curses.clrtoeol
127
- Curses.addstr(status.to_s)
164
+ status.nodes.each { addnode(_1) }
128
165
  context = status.context_bar
129
- Curses.setpos(Curses.lines - offset, [(columns - context.length) / 2, 0].max)
166
+ Curses.setpos(y, [(columns - context.length) / 2, 0].max)
130
167
  Curses.addstr(context)
131
168
  cost = status.cost.to_s
132
- Curses.setpos(Curses.lines - offset, [columns - cost.length, 0].max)
169
+ Curses.setpos(y, [columns - cost.length, 0].max)
133
170
  Curses.addstr(cost)
134
171
  end
135
172
 
173
+ ##
174
+ # Draws the top chrome row (model left, cwd right) at the very top
175
+ # of the screen, with a blue background.
176
+ # @param [Integer] offset
177
+ # @return [void]
178
+ def draw_meta(offset:)
179
+ y = Curses.lines - offset
180
+ fill_row(y) do
181
+ addnode(status.cwd, status.cwd.text[0, columns])
182
+ Curses.setpos(y, [columns - status.model.text.size, 0].max)
183
+ addnode(status.model, status.model.text[0, columns])
184
+ end
185
+ end
186
+
187
+ ##
188
+ # Fills an entire row with the status-bar background color, then
189
+ # draws the given content on top (which inherits the blue
190
+ # background), and resets the background afterwards.
191
+ # @param [Integer] y
192
+ # @yield Content to draw on the row
193
+ # @return [void]
194
+ def fill_row(y, &)
195
+ Curses.setpos(y, 0)
196
+ Curses.bkgdset(Color.statusbar)
197
+ Curses.clrtoeol
198
+ yield
199
+ Curses.bkgdset(0)
200
+ end
201
+
202
+ ##
203
+ # Draws a horiztonal line
204
+ # @return [void]
136
205
  def draw_divider(offset:)
137
206
  Curses.setpos(Curses.lines - offset, 0)
138
207
  Curses.clrtoeol
139
208
  Curses.addstr("─" * Curses.cols)
140
209
  end
141
210
 
211
+ ##
212
+ # Draws the input area
213
+ # @return [void]
142
214
  def draw_input
143
215
  rows = input.lines
144
216
  (0...input.height).each do |idx|
@@ -148,12 +220,40 @@ class LLM::Repl
148
220
  end
149
221
  line, col = input.cursor_pos
150
222
  Curses.setpos((Curses.lines - input.height) + line, col)
223
+ show_cursor
151
224
  end
152
225
 
153
226
  ##
227
+ # Hides the cursor.
228
+ # @return [void]
229
+ def hide_cursor
230
+ Curses.curs_set(0)
231
+ end
232
+
233
+ ##
234
+ # Shows the cursor.
235
+ # @return [void]
236
+ def show_cursor
237
+ Curses.curs_set(1)
238
+ end
239
+
240
+ ##
241
+ # 20% offset that occupies the left margin and
242
+ # helps center {LLM::Repl::Buffer LLM::Repl::Buffer}.
154
243
  # @return [Integer]
155
- def columns
156
- Curses.cols
244
+ def gutter
245
+ (columns * 0.2).floor
246
+ end
247
+
248
+ ##
249
+ # Adds a {LLM::Repl::Node} to the Curses window.
250
+ # @param [LLM::Repl::Node] node
251
+ # @param [String] text
252
+ # @return [void]
253
+ def addnode(node, text = node.text)
254
+ Curses.attron(node.attrs) if node.attrs
255
+ Curses.addstr(text)
256
+ Curses.attroff(node.attrs) if node.attrs
157
257
  end
158
258
  end
159
259
  end
data/lib/llm/repl.rb CHANGED
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- LLM.require "curses"
4
- LLM.require "kramdown"
5
-
6
3
  module LLM
7
4
  ##
8
5
  # The {LLM::Repl LLM::Repl} class provides a small
@@ -15,6 +12,10 @@ module LLM
15
12
  # task correctly, or for it to correct course after a
16
13
  # mistake was made.
17
14
  class Repl
15
+ LLM.require "curses", "~> 1.6"
16
+ LLM.require "kramdown", "~> 2.5"
17
+
18
+ require_relative "repl/color"
18
19
  require_relative "repl/window"
19
20
  require_relative "repl/status"
20
21
  require_relative "repl/buffer"
@@ -29,7 +30,7 @@ module LLM
29
30
  attr_reader :agent, :provider, :stream,
30
31
  :status, :buffer, :input,
31
32
  :window, :tools, :thread,
32
- :name, :path, :width
33
+ :name, :path
33
34
 
34
35
  ##
35
36
  # @param [LLM::Agent] agent
@@ -43,14 +44,15 @@ module LLM
43
44
  # Zero or more skills
44
45
  # @return [LLM::Repl]
45
46
  def initialize(agent:, name: nil, tools: [], skills: [], path: nil)
46
- @width = 80
47
47
  @path = path
48
48
  @name = name || "agent"
49
+ @sender = "You"
49
50
  @agent = configure(agent:, path:)
50
51
  @provider = agent.llm.name
51
- @status = Status.new(self)
52
- @buffer = Buffer.new(self)
52
+ @model = agent.model
53
53
  @input = Input.new(self, height: 3)
54
+ @buffer = Buffer.new(self)
55
+ @status = Status.new(self)
54
56
  @window = Window.new(self)
55
57
  @thread = nil
56
58
  @queue = Queue.new
@@ -112,7 +114,7 @@ module LLM
112
114
  # @param [String] chars
113
115
  # @return [Array<Node>]
114
116
  def markdown(chars)
115
- LLM::Repl::Markdown.new(chars, width).ast
117
+ LLM::Repl::Markdown.new(chars, buffer.width).ast
116
118
  end
117
119
 
118
120
  ##
@@ -125,8 +127,34 @@ module LLM
125
127
 
126
128
  ##
127
129
  # @return [String]
128
- def thinking_text
129
- "thinking • Esc to cancel"
130
+ def think_text
131
+ " 🧠 • Thinking • Esc to cancel"
132
+ end
133
+
134
+ ##
135
+ # @return [String]
136
+ def connect_text
137
+ " 🌐 • Connecting • Esc to cancel"
138
+ end
139
+
140
+ ##
141
+ # Returns a label for the person who sent a message.
142
+ # @return [String]
143
+ def sender
144
+ @sender
145
+ end
146
+
147
+ ##
148
+ # Returns the active model.
149
+ # @return [String]
150
+ def model
151
+ @model
152
+ end
153
+
154
+ ##
155
+ # Sets the active model.
156
+ def model=(other)
157
+ @model = other
130
158
  end
131
159
 
132
160
  private
@@ -138,11 +166,11 @@ module LLM
138
166
  def tree(messages)
139
167
  messages.flat_map do |message|
140
168
  next if message.tool_call? || message.tool_return?
141
- user = message.assistant? ? name : "user"
169
+ user = message.assistant? ? name : sender
142
170
  [
143
- Node.new("#{user}: ", Curses::A_BOLD),
144
- Node.new(message.content),
145
- Node.new(user == name ? "\n\n" : "\n")
171
+ Node.new("#{user}:\n", Curses::A_BOLD | Color.blue),
172
+ *markdown(message.content),
173
+ Node.new("\n\n")
146
174
  ]
147
175
  end.compact
148
176
  end
@@ -180,17 +208,15 @@ module LLM
180
208
  case on_text(text)
181
209
  in [:command, Command => command, Hash => parameters]
182
210
  command.call(**parameters)
183
- write("\n")
184
211
  in [:error, String => user, String => text]
185
212
  write_message(user, text)
186
- write("\n")
187
213
  in [:input, String => text]
188
214
  window.scroll_to_bottom
189
- status.text = thinking_text
190
- write_message("user", markdown(text))
215
+ status.text = connect_text
216
+ write_message(sender, markdown(text))
191
217
  @thread = Thread.new do
192
218
  @queue << [:start]
193
- agent.talk(text, tools:, stream:)
219
+ agent.talk(text, model:, tools:, stream:)
194
220
  agent.save(path:) if save?
195
221
  @queue << [:done]
196
222
  rescue LLM::Interrupt => e
@@ -245,6 +271,7 @@ module LLM
245
271
  buffer.open
246
272
  stream.clear
247
273
  when :stream
274
+ status.text = think_text if stream.tools.empty?
248
275
  write_message name, markdown(value), method: :replace
249
276
  when :status
250
277
  self.status = value
@@ -252,13 +279,12 @@ module LLM
252
279
  status.text = "idle"
253
280
  buffer.close
254
281
  @thread = nil
255
- write("\n")
256
282
  when :cancel
283
+ stream.tools.clear
257
284
  buffer.close
258
285
  status.text = "Idle"
259
286
  write_message(name, "Request cancelled")
260
287
  @thread = nil
261
- write("\n")
262
288
  when :error
263
289
  status.text = "error"
264
290
  write_message(name, "(#{value.class}): #{value.message}")
@@ -236,16 +236,30 @@ module LLM::Sequel
236
236
  # @return [Integer]
237
237
  def context_window
238
238
  ctx.context_window
239
- rescue LLM::NoSuchModelError, LLM::NoSuchRegistryError
240
- 0
239
+ end
240
+
241
+ ##
242
+ # Returns how many tokens have been used
243
+ # within the context window
244
+ # @return [Integer]
245
+ def context_used
246
+ ctx.context_used
247
+ end
248
+
249
+ ##
250
+ # Returns context window usage as a Rational
251
+ # @return [Rational, nil]
252
+ def context_usage
253
+ ctx.context_usage
241
254
  end
242
255
 
243
256
  ##
244
257
  # Returns usage from the mapped usage columns.
245
- # @return [LLM::Object]
246
- def usage
247
- ctx.usage || LLM::Object.from(input_tokens: 0, output_tokens: 0, total_tokens: 0)
258
+ # @return [LLM::Usage]
259
+ def token_usage
260
+ ctx.token_usage
248
261
  end
262
+ alias_method :usage, :token_usage
249
263
 
250
264
  ##
251
265
  # @see LLM::Context#interrupt!
data/lib/llm/skill.rb CHANGED
@@ -80,19 +80,25 @@ module LLM
80
80
  end
81
81
 
82
82
  ##
83
- # Execute the skill by wrapping it in a small agent with the skill
84
- # instructions. The context is bound explicitly by the caller so the
85
- # nested agent can inherit context-level behavior such as streaming.
83
+ # Execute the skill by wrapping it in a small agent
84
+ # with the skill instructions. The context is bound
85
+ # explicitly by the caller so the nested agent can
86
+ # inherit context-level behavior such as streaming.
86
87
  # @param [LLM::Context] ctx
87
88
  # @return [Hash]
88
89
  def call(ctx)
89
- content = agent(ctx).talk("Solve the user's query.").content
90
- {content:}
90
+ stream = ctx.stream
91
+ stream.on_skill_call(self)
92
+ agent = self.agent(ctx)
93
+ res = agent.talk("Solve the user's query.")
94
+ stream.on_skill_return(agent, self, res)
95
+ {content: res.content}
91
96
  end
92
97
 
93
98
  ##
94
- # Expose the skill as a normal LLM::Tool. The context is bound explicitly
95
- # when the tool class is built.
99
+ # Expose the skill as a normal LLM::Tool. The
100
+ # context is bound explicitly when the tool class
101
+ # is built.
96
102
  # @param [LLM::Context] ctx
97
103
  # @return [Class<LLM::Tool>]
98
104
  def to_tool(ctx)
@@ -100,7 +106,14 @@ module LLM
100
106
  Class.new(LLM::Tool) do
101
107
  attr_accessor :tracer
102
108
 
103
- name skill.name
109
+ ##
110
+ # The skill's tool is named "<skill>-skill" so
111
+ # that it is unique from any tool the skill
112
+ # itself exposes (or any global tool). Otherwise
113
+ # a skill named `weather` that uses a `weather`
114
+ # tool would collide: both resolve to the same
115
+ # name.
116
+ name "#{skill.name}-skill"
104
117
  description skill.description
105
118
 
106
119
  define_singleton_method(:skill?) do
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
 
@@ -183,6 +184,33 @@ module LLM
183
184
  nil
184
185
  end
185
186
 
187
+ ##
188
+ # Called when a request is rate limited and will be retried.
189
+ # @param [LLM::RateLimitError] error
190
+ # @return [nil]
191
+ def on_rate_limit(error)
192
+ nil
193
+ end
194
+
195
+ ##
196
+ # Called before a skill's subagent runs.
197
+ # @param [LLM::Skill] skill
198
+ # @return [nil]
199
+ def on_skill_call(skill)
200
+ nil
201
+ end
202
+
203
+ ##
204
+ # Called after a skill's subagent finishes.
205
+ # @param [LLM::Agent] agent
206
+ # The subagent that ran the skill
207
+ # @param [LLM::Skill] skill
208
+ # @param [LLM::Response, nil] result
209
+ # @return [nil]
210
+ def on_skill_return(agent, skill, result)
211
+ nil
212
+ end
213
+
186
214
  # @endgroup
187
215
 
188
216
  # @group Finders