llm.rb 15.0.3 → 15.2.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -3
  3. data/README.md +188 -71
  4. data/bin/llm.rb +50 -7
  5. data/data/alibaba.json +912 -823
  6. data/data/anthropic.json +234 -187
  7. data/data/bedrock.json +3702 -2058
  8. data/data/deepinfra.json +1288 -951
  9. data/data/deepseek.json +87 -53
  10. data/data/google.json +670 -670
  11. data/data/mistral.json +501 -460
  12. data/data/moonshot.json +43 -248
  13. data/data/openai.json +1008 -914
  14. data/data/openrouter.json +14417 -0
  15. data/data/xai.json +213 -201
  16. data/data/zai.json +242 -149
  17. data/docs/deepdive/advanced/compaction.md +5 -5
  18. data/docs/deepdive/advanced/context.md +8 -6
  19. data/docs/deepdive/advanced/guard.md +2 -2
  20. data/docs/deepdive/features/builtin_tools.md +93 -22
  21. data/docs/deepdive/features/{repl.md → console.md} +28 -28
  22. data/docs/deepdive/features/database.md +3 -3
  23. data/docs/deepdive/fundamentals/agents.md +13 -12
  24. data/docs/deepdive/fundamentals/providers.md +91 -6
  25. data/docs/deepdive/fundamentals/skills.md +14 -6
  26. data/docs/deepdive/fundamentals/stream.md +4 -4
  27. data/docs/deepdive/fundamentals/tools.md +63 -31
  28. data/docs/deepdive/reference/cost.md +2 -2
  29. data/docs/deepdive/reference/model_registry.md +2 -2
  30. data/docs/deepdive/reference/tracer.md +15 -13
  31. data/docs/deepdive.md +2 -2
  32. data/lib/llm/active_record/acts_as_agent.rb +9 -5
  33. data/lib/llm/agent.rb +40 -15
  34. data/lib/llm/{repl → console}/bar.rb +3 -3
  35. data/lib/llm/{repl → console}/buffer.rb +24 -9
  36. data/lib/llm/{repl → console}/color.rb +2 -2
  37. data/lib/llm/{repl → console}/command.rb +12 -12
  38. data/lib/llm/{repl → console}/commands/exit.rb +4 -4
  39. data/lib/llm/{repl → console}/commands/help.rb +1 -1
  40. data/lib/llm/{repl/commands/compact.rb → console/commands/keep.rb} +11 -9
  41. data/lib/llm/{repl → console}/commands/model.rb +2 -2
  42. data/lib/llm/{repl → console}/input/cache.rb +2 -2
  43. data/lib/llm/{repl → console}/input/char.rb +2 -2
  44. data/lib/llm/{repl → console}/input/row.rb +1 -1
  45. data/lib/llm/{repl → console}/input.rb +18 -10
  46. data/lib/llm/console/markdown/parser.rb +78 -0
  47. data/lib/llm/{repl → console}/markdown/table.rb +8 -5
  48. data/lib/llm/{repl → console}/markdown.rb +13 -30
  49. data/lib/llm/console/node.rb +69 -0
  50. data/lib/llm/{repl → console}/status.rb +11 -11
  51. data/lib/llm/{repl → console}/stream.rb +36 -9
  52. data/lib/llm/{repl → console}/walker.rb +1 -1
  53. data/lib/llm/{repl → console}/window.rb +17 -17
  54. data/lib/llm/{repl.rb → console.rb} +39 -19
  55. data/lib/llm/context/deserializer.rb +2 -1
  56. data/lib/llm/context.rb +29 -13
  57. data/lib/llm/cost.rb +13 -0
  58. data/lib/llm/function/async/reactor.rb +20 -1
  59. data/lib/llm/function/fork/task.rb +14 -10
  60. data/lib/llm/function.rb +1 -1
  61. data/lib/llm/json_adapter.rb +40 -28
  62. data/lib/llm/message.rb +7 -0
  63. data/lib/llm/provider.rb +31 -10
  64. data/lib/llm/providers/alibaba.rb +1 -1
  65. data/lib/llm/providers/anthropic.rb +1 -1
  66. data/lib/llm/providers/bedrock/models.rb +2 -2
  67. data/lib/llm/providers/bedrock.rb +1 -1
  68. data/lib/llm/providers/deepseek.rb +1 -1
  69. data/lib/llm/providers/google.rb +1 -1
  70. data/lib/llm/providers/ollama.rb +1 -1
  71. data/lib/llm/providers/openai/responses.rb +2 -1
  72. data/lib/llm/providers/openai.rb +2 -1
  73. data/lib/llm/providers/openrouter.rb +87 -0
  74. data/lib/llm/schema/leaf.rb +34 -2
  75. data/lib/llm/schema.rb +4 -2
  76. data/lib/llm/sequel/agent.rb +9 -5
  77. data/lib/llm/skill.rb +7 -1
  78. data/lib/llm/stream.rb +8 -3
  79. data/lib/llm/tool/param.rb +5 -1
  80. data/lib/llm/tool.rb +5 -0
  81. data/lib/llm/tools/bundle.rb +53 -0
  82. data/lib/llm/tools/edit-file.rb +7 -2
  83. data/lib/llm/tools/exec.rb +78 -0
  84. data/lib/llm/tools/git.rb +27 -26
  85. data/lib/llm/tools/mkdir.rb +12 -19
  86. data/lib/llm/tools/read_file.rb +69 -9
  87. data/lib/llm/tools/rg.rb +20 -24
  88. data/lib/llm/tools/ruby.rb +17 -25
  89. data/lib/llm/tools/utils.rb +75 -2
  90. data/lib/llm/tools/write_file.rb +4 -1
  91. data/lib/llm/tracer/logger.rb +2 -2
  92. data/lib/llm/tracer/pretty_logger.rb +4 -4
  93. data/lib/llm/tracer/telemetry.rb +2 -2
  94. data/lib/llm/tracer.rb +33 -0
  95. data/lib/llm/transport/curb.rb +5 -3
  96. data/lib/llm/transport/http.rb +5 -2
  97. data/lib/llm/transport/persistent_http.rb +6 -4
  98. data/lib/llm/transport/utils.rb +8 -6
  99. data/lib/llm/version.rb +1 -1
  100. data/lib/llm.rb +18 -12
  101. data/llm.gemspec +8 -8
  102. metadata +80 -37
  103. data/lib/llm/repl/node.rb +0 -44
  104. data/lib/llm/tools/shell.rb +0 -55
@@ -210,7 +210,7 @@ module LLM
210
210
  end
211
211
 
212
212
  def normalize_complete_params(params)
213
- params = {role: :user, model: default_model, max_tokens: 2048}.merge!(params)
213
+ params = {role: :user, model: params.delete(:model) || default_model, max_tokens: 2048}.merge!(params)
214
214
  tools = resolve_tools(params.delete(:tools))
215
215
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
216
216
  role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
@@ -78,7 +78,7 @@ module LLM
78
78
  # @see https://api-docs.deepseek.com/quick_start/pricing deepseek-v4-flash
79
79
  # @return [String]
80
80
  def default_model
81
- "deepseek-v4-flash"
81
+ "deepseek-flash"
82
82
  end
83
83
  end
84
84
  end
@@ -202,7 +202,7 @@ module LLM
202
202
 
203
203
  def normalize_complete_params(params)
204
204
  except = %i[role model messages stream]
205
- params = {role: :user, model: default_model}.merge!(params)
205
+ params = {role: :user, model: params.delete(:model) || default_model}.merge!(params)
206
206
  tools = resolve_tools(params.delete(:tools))
207
207
  config = adapt_generation_config(params.except(*except))
208
208
  params = [params.except(:schema), config, adapt_tools(tools)].inject({}, &:merge!).compact
@@ -129,7 +129,7 @@ module LLM
129
129
  end
130
130
 
131
131
  def normalize_complete_params(params)
132
- params = {role: :user, model: default_model, stream: true}.merge!(params)
132
+ params = {role: :user, model: params.delete(:model) || default_model, stream: true}.merge!(params)
133
133
  tools = resolve_tools(params.delete(:tools))
134
134
  params = [params, {format: params[:schema]}, adapt_tools(tools)].inject({}, &:merge!).compact
135
135
  role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
@@ -35,7 +35,8 @@ class LLM::OpenAI
35
35
  # When given an object a provider does not understand
36
36
  # @return [LLM::Response]
37
37
  def create(prompt, params = {})
38
- params = {role: :user, model: @provider.default_model}.merge!(params)
38
+ params = {}.merge!(params)
39
+ params = {role: :user, model: params.delete(:model) || @provider.default_model}.merge!(params)
39
40
  role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
40
41
  tools = resolve_tools(params.delete(:tools))
41
42
  params = [
@@ -219,7 +219,7 @@ module LLM
219
219
  end
220
220
 
221
221
  def normalize_complete_params(params)
222
- params = {role: :user, model: default_model}.merge!(params)
222
+ params = {role: :user, model: params.delete(:model) || default_model}.merge!(params)
223
223
  tools = resolve_tools(params.delete(:tools))
224
224
  params = [params, adapt_schema(params), adapt_tools(tools)].inject({}, &:merge!).compact
225
225
  role, stream = params.delete(:role), LLM::Stream.try(params.delete(:stream))
@@ -231,6 +231,7 @@ module LLM
231
231
 
232
232
  def build_complete_request(prompt, params, role)
233
233
  messages = build_messages(prompt, params, role)
234
+ messages = [*params.delete(:messages), *messages] if params[:messages]
234
235
  body = LLM.json.dump({messages: adapt(messages, mode: :complete).flatten}.merge!(params))
235
236
  req = LLM::Transport::Request.post(completions_path, headers)
236
237
  transport.set_body_stream(req, StringIO.new(body))
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "openai" unless defined?(LLM::OpenAI)
4
+
5
+ module LLM
6
+ ##
7
+ # The OpenRouter class implements a provider for
8
+ # [OpenRouter](https://openrouter.ai) through its OpenAI-compatible API.
9
+ #
10
+ # @example
11
+ # #!/usr/bin/env ruby
12
+ # require "llm"
13
+ #
14
+ # llm = LLM.openrouter(key: ENV["KEY"])
15
+ # ctx = LLM::Context.new(llm)
16
+ # ctx.talk "Hello"
17
+ class OpenRouter < OpenAI
18
+ HOST = "openrouter.ai"
19
+ BASE_PATH = "/api/v1"
20
+
21
+ ##
22
+ # @param key (see LLM::Provider#initialize)
23
+ # @param host (see LLM::Provider#initialize)
24
+ # @param base_path (see LLM::Provider#initialize)
25
+ # @return [LLM::OpenRouter]
26
+ def initialize(host: HOST, base_path: BASE_PATH, **)
27
+ super
28
+ end
29
+
30
+ ##
31
+ # @return [Symbol]
32
+ # Returns the provider's name
33
+ def name
34
+ :openrouter
35
+ end
36
+
37
+ ##
38
+ # Provides an embedding.
39
+ # @see https://openrouter.ai/docs/api/api-reference/embeddings/create-embeddings OpenRouter docs
40
+ # @param input (see LLM::Provider#embed)
41
+ # @param model (see LLM::Provider#embed)
42
+ # @param params (see LLM::Provider#embed)
43
+ # @raise (see LLM::Provider#request)
44
+ # @return (see LLM::Provider#embed)
45
+ def embed(input, model: "openai/text-embedding-3-small", **params)
46
+ super
47
+ end
48
+
49
+ ##
50
+ # @raise [NotImplementedError]
51
+ def files
52
+ raise NotImplementedError
53
+ end
54
+
55
+ ##
56
+ # @raise [NotImplementedError]
57
+ def images
58
+ raise NotImplementedError
59
+ end
60
+
61
+ ##
62
+ # @raise [NotImplementedError]
63
+ def audio
64
+ raise NotImplementedError
65
+ end
66
+
67
+ ##
68
+ # @raise [NotImplementedError]
69
+ def moderations
70
+ raise NotImplementedError
71
+ end
72
+
73
+ ##
74
+ # @raise [NotImplementedError]
75
+ def vector_stores
76
+ raise NotImplementedError
77
+ end
78
+
79
+ ##
80
+ # Returns the default model for chat completions
81
+ # @see https://openrouter.ai/docs/guides/routing/routers/auto-router OpenRouter Auto Router
82
+ # @return [String]
83
+ def default_model
84
+ "openrouter/auto"
85
+ end
86
+ end
87
+ end
@@ -19,6 +19,26 @@ class LLM::Schema
19
19
  @required = nil
20
20
  @const = nil
21
21
  @index = nil
22
+ @owner = nil
23
+ end
24
+
25
+ ##
26
+ # @param [Object] owner
27
+ # The owner of a schema, typically an LLM::Tool or LLM::Schema class
28
+ # @return [void]
29
+ def owner=(owner)
30
+ @owner = owner
31
+ end
32
+
33
+ ##
34
+ # Resolves a configured value against the owner,
35
+ # following {LLM::Utils#resolve_option}. A Proc runs
36
+ # against the owner, a Symbol is sent to it, and
37
+ # everything else passes through unchanged.
38
+ # @param [Object] value
39
+ # @return [Object]
40
+ def resolve(value)
41
+ LLM::Utils.resolve_option(@owner, value, resolve_symbol: true)
22
42
  end
23
43
 
24
44
  ##
@@ -39,7 +59,7 @@ class LLM::Schema
39
59
  # @return [LLM::Schema::Leaf]
40
60
  def default(value = nil)
41
61
  if value.nil?
42
- @default
62
+ resolve(@default)
43
63
  else
44
64
  tap { @default = value }
45
65
  end
@@ -100,7 +120,7 @@ class LLM::Schema
100
120
  ##
101
121
  # @return [Hash]
102
122
  def to_h
103
- {description: @description, default: @default, enum: @enum, const: @const}.compact
123
+ {description: @description, default:, enum: @enum, const: @const}.compact
104
124
  end
105
125
 
106
126
  ##
@@ -124,5 +144,17 @@ class LLM::Schema
124
144
  to_h == other.to_h
125
145
  end
126
146
  alias_method :eql?, :==
147
+
148
+ ##
149
+ # @return [Integer]
150
+ def hash
151
+ to_h.hash
152
+ end
153
+
154
+ private
155
+
156
+ def owner
157
+ @owner
158
+ end
127
159
  end
128
160
  end
data/lib/llm/schema.rb CHANGED
@@ -128,8 +128,10 @@ class LLM::Schema
128
128
  def self.defaults(defaults)
129
129
  lock do
130
130
  object.tap do |schema|
131
- defaults.each do |name, val|
132
- Utils.fetch(schema.properties, name).default(val)
131
+ defaults.each do |name, value|
132
+ leaf = Utils.fetch(schema.properties, name)
133
+ leaf.owner = self
134
+ leaf.default(value)
133
135
  end
134
136
  end
135
137
  end
@@ -35,7 +35,10 @@ module LLM::Sequel
35
35
  ##
36
36
  # @return [Class<LLM::Agent>]
37
37
  def agent
38
- @agent ||= Class.new(LLM::Agent)
38
+ return @agent if defined?(@agent)
39
+ @agent = Class.new(LLM::Agent)
40
+ @agent.name(self)
41
+ @agent
39
42
  end
40
43
 
41
44
  ##
@@ -75,11 +78,12 @@ module LLM::Sequel
75
78
  # This method does not persist to the database,
76
79
  # but it can inspect and alter runtime state in
77
80
  # a way that is temporary.
78
- # @param (see LLM::Agent#repl)
79
- # @return (see LLM::Agent#repl)
80
- def repl(**params)
81
- ctx.repl(**params)
81
+ # @param (see LLM::Agent#console)
82
+ # @return (see LLM::Agent#console)
83
+ def console(**params)
84
+ ctx.console(**params)
82
85
  end
86
+ alias_method :repl, :console
83
87
 
84
88
  private
85
89
 
data/lib/llm/skill.rb CHANGED
@@ -35,6 +35,11 @@ module LLM
35
35
  # @return [String]
36
36
  attr_reader :description
37
37
 
38
+ ##
39
+ # Returns the model the skill will use.
40
+ # @return [String, nil]
41
+ attr_reader :model
42
+
38
43
  ##
39
44
  # Returns the skill instructions.
40
45
  # @return [String]
@@ -90,7 +95,7 @@ module LLM
90
95
  stream = ctx.stream
91
96
  stream.on_skill_call(self)
92
97
  agent = self.agent(ctx)
93
- res = agent.talk("Solve the user's query.")
98
+ res = agent.talk("Solve the user's query.", model: model || ctx.model)
94
99
  stream.on_skill_return(agent, self, res)
95
100
  {content: res.content}
96
101
  end
@@ -156,6 +161,7 @@ module LLM
156
161
  @frontmatter = LLM::Object.from(YAML.safe_load(match[1]) || {})
157
162
  @name = @frontmatter.name || @name
158
163
  @description = @frontmatter.description || @description
164
+ @model = @frontmatter.model || nil
159
165
  @instructions = match[2]
160
166
  @inherit_tools, @tools = parse_tools(@frontmatter.tools)
161
167
  end
data/lib/llm/stream.rb CHANGED
@@ -185,12 +185,17 @@ module LLM
185
185
  end
186
186
 
187
187
  ##
188
- # Called when a request is rate limited and will be retried.
189
- # @param [LLM::RateLimitError] error
188
+ # Called when a request is retried because it was rate limited or
189
+ # timed out.
190
+ # @param [Exception] error
191
+ # The error that caused the retry
192
+ # @param [Integer] attempt
193
+ # The one-based retry attempt number
190
194
  # @return [nil]
191
- def on_rate_limit(error)
195
+ def on_retry(error, attempt)
192
196
  nil
193
197
  end
198
+ alias_method :on_rate_limit, :on_retry
194
199
 
195
200
  ##
196
201
  # Called before a skill's subagent runs.
@@ -63,7 +63,11 @@ class LLM::Tool
63
63
  def defaults(defaults)
64
64
  lock do
65
65
  function.params.tap do |schema|
66
- defaults.each { Utils.fetch(schema.properties, _1).default(_2) }
66
+ defaults.each do |name, value|
67
+ leaf = Utils.fetch(schema.properties, name)
68
+ leaf.owner = self
69
+ leaf.default(value)
70
+ end
67
71
  end
68
72
  end
69
73
  end
data/lib/llm/tool.rb CHANGED
@@ -34,6 +34,11 @@
34
34
  # @see LLM::Agent Tools are attached to agents
35
35
  # @see LLM::Function The function object that Tool wraps
36
36
  class LLM::Tool
37
+ ##
38
+ # @api private
39
+ UNDEFINED = Object.new
40
+ private_constant :UNDEFINED
41
+
37
42
  require_relative "tool/param"
38
43
  extend LLM::Tool::Param
39
44
  extend LLM::Function::Registry
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Tool
4
+ ##
5
+ # The {LLM::Tool::Bundle} class implements
6
+ # a tool that runs a command through `bundle`.
7
+ # It inherits the `BUNDLE_GEMFILE` environment
8
+ # variable when set, or defaults to a `Gemfile`
9
+ # in the current working directory.
10
+ class Bundle < self
11
+ require_relative "exec"
12
+
13
+ name "bundle"
14
+ description "Run the 'bundle' command\n" \
15
+ "This command (bundle) is spawned without a shell"
16
+ parameter :arguments, Array[String], "one or more command arguments"
17
+ parameter :timeout, Integer, "the maximum allowed time for the command to run (in seconds)"
18
+ parameter :max_bytes, Integer, "max number of bytes to emit"
19
+ defaults arguments: [], timeout: 60, max_bytes: -> { Exec.max_bytes }
20
+
21
+ ##
22
+ # @return [LLM::Tool::BundleExec]
23
+ def initialize
24
+ @env = {"BUNDLE_GEMFILE" => ENV["BUNDLE_GEMFILE"] || File.join(Dir.getwd, "Gemfile")}
25
+ end
26
+
27
+ ##
28
+ # @param [Array<String>] arguments
29
+ # One or more command-line arguments
30
+ # @param [Integer] timeout
31
+ # The maximum allowed time for the command to run (in seconds)
32
+ # @param [Integer] max_bytes
33
+ # Max number of bytes to emit
34
+ # @return [Hash]
35
+ def call(arguments: [], timeout: 60, max_bytes: Exec.max_bytes)
36
+ Exec.new(env:).call(
37
+ arguments: ["bundle", *arguments],
38
+ timeout:,
39
+ max_bytes:
40
+ )
41
+ end
42
+
43
+ private
44
+
45
+ ##
46
+ # Returns the `Gemfile` used by this tool:
47
+ # the `BUNDLE_GEMFILE` environment variable
48
+ # when set, otherwise a `Gemfile` in the
49
+ # current working directory.
50
+ # @return [Hash{String => String}]
51
+ attr_reader :env
52
+ end
53
+ end
@@ -22,9 +22,14 @@ class LLM::Tool
22
22
  # @return [Hash]
23
23
  def call(path:, before:, after:, expected_count: 1)
24
24
  content = File.read(path)
25
- count = content.scan(before).length
25
+ re = Regexp.new(Regexp.escape(before))
26
+ count = content.scan(re).length
26
27
  raise "expected #{expected_count} match(es), found #{count}" unless count == expected_count.to_i
27
- File.write(path, content.sub(before, after))
28
+ ##
29
+ # Block form keeps the replacement literal:
30
+ # `sub(re, str)` would expand backreferences
31
+ # like \1 and \& in the replacement text.
32
+ File.write(path, content.sub(re) { after })
28
33
  {ok: true, replaced: count}
29
34
  end
30
35
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ class LLM::Tool
4
+ ##
5
+ # The {LLM::Tool::Exec} class implements a tool that can
6
+ # spawn a command. That can be dangerous given a low-quality
7
+ # model, or a high-quality model that simply makes a bad
8
+ # decision. The risk can be reduced through a confirmation
9
+ # step such as {LLM::Agent.confirm LLM::Agent.confirm}, or
10
+ # by managing the tool loop manually through
11
+ # {LLM::Context LLM::Context}.
12
+ class Exec < self
13
+ require_relative "utils"
14
+ include Utils
15
+
16
+ name "exec"
17
+ description "Run a command without a shell"
18
+ parameter :arguments, Array[String], "a command and its argument(s)"
19
+ parameter :timeout, Integer, "the maximum allowed time for the command to run (in seconds)"
20
+ parameter :max_bytes, Integer, "max number of bytes to emit"
21
+ required %i[arguments]
22
+ defaults timeout: 60, max_bytes: :max_bytes
23
+
24
+ ##
25
+ # Returns (or sets) the advisory maximum number of bytes
26
+ # this tool returns to the model.
27
+ # @param [Integer, nil] bytes
28
+ # When given, sets the maximum
29
+ # @return [Integer]
30
+ def self.max_bytes(bytes = UNDEFINED)
31
+ if bytes.equal?(UNDEFINED)
32
+ @max_bytes || 75_000
33
+ else
34
+ @max_bytes = bytes
35
+ end
36
+ end
37
+
38
+ ##
39
+ # @param [Hash] env
40
+ # Extra environment variables to set for the command.
41
+ # This is configuration for the tool instance, not a
42
+ # model-provided parameter.
43
+ # @return [LLM::Tool::Exec]
44
+ def initialize(env: {})
45
+ @env = env
46
+ end
47
+
48
+ ##
49
+ # @param [String] name
50
+ # The name of a command
51
+ # @param [Array<String>] arguments
52
+ # One or more command-line arguments
53
+ # @param [Integer] timeout
54
+ # The maximum allowed time for the command to run (in seconds)
55
+ # @param [Integer] max_bytes
56
+ # the max number of bytes to emit
57
+ # @return [Hash]
58
+ def call(arguments: [], timeout: 60, max_bytes: self.class.max_bytes)
59
+ name = arguments[0]
60
+ command = spawn(name:, arguments: arguments[1..], env:, max_bytes:)
61
+ wait(command:, timeout:)
62
+ if command.not_found?
63
+ {ok: false, error: "command '#{name}' was not found on this system"}
64
+ else
65
+ {ok: command.success?,
66
+ stdout: truncate(command.stdout, max_bytes:),
67
+ stderr: truncate(command.stderr, max_bytes:)}
68
+ end
69
+ rescue LLM::Interrupt
70
+ command.kill! if command&.running?
71
+ raise
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :env
77
+ end
78
+ end
data/lib/llm/tools/git.rb CHANGED
@@ -3,45 +3,46 @@
3
3
  class LLM::Tool
4
4
  ##
5
5
  # The {LLM::Tool::Git LLM::Tool::Git} class implements
6
- # a tool that can perform a select number of git actions.
7
- # The actions it can perform are read-only - at least for
8
- # the time being.
6
+ # a tool that can perform a select number of git subcommands.
9
7
  class Git < self
10
- require_relative "utils"
11
- include Utils
8
+ require_relative "exec"
12
9
 
13
10
  name "git"
14
- description "perform an action with git"
15
- parameter :action, Enum["log", "diff", "commit", "checkout", "branch", "show"], "the git operation to perform"
16
- parameter :arguments, Array[String], "one or more arguments for the git action"
17
- parameter :timeout, Integer, "the maximum time to allow the command to run"
18
- required %i[action]
11
+ description "Perform a git subcommand\n" \
12
+ "This command (git) is spawned without a shell"
13
+ parameter :arguments, Array[String], "one or more arguments forwarded to git"
14
+ parameter :timeout, Integer, "the maximum time to allow the command to run (in seconds)"
15
+ required %i[arguments]
19
16
  defaults arguments: [], timeout: 5
20
17
 
21
18
  ##
22
- # @param [String] action
19
+ # @param [String] subcommand
23
20
  # @param [Array<String>, nil] arguments
24
21
  # @return [Hash]
25
- def call(action:, arguments: [], timeout: 5)
26
- command = spawn(action:, arguments:)
27
- wait(command:, timeout:)
28
- {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
29
- rescue LLM::Interrupt
30
- command.kill! if command&.running?
31
- raise
22
+ def call(arguments: [], timeout: 5)
23
+ subcommand = arguments[0]
24
+ validate!(subcommand:, arguments:)
25
+ Exec.new.call(
26
+ arguments: ["git", subcommand, *arguments[1..]],
27
+ timeout:
28
+ )
32
29
  end
33
30
 
34
31
  private
35
32
 
36
- def spawn(action:, arguments:)
37
- Command
38
- .new("git")
39
- .argv(action)
40
- .argv(*[*arguments])
41
- .spawn
33
+ ##
34
+ # @return [void]
35
+ def validate!(subcommand:, arguments:)
36
+ unless subcommands.include?(subcommand.to_s)
37
+ raise RuntimeError, "git subcommand must be one of: #{subcommands.join(",")}"
38
+ end
42
39
  end
43
40
 
44
- LLM.require "test-cmd.rb", "~> 1.1"
45
- Command = Test::Cmd
41
+ ##
42
+ # @return [Array<String>]
43
+ # The git subcommands that can be performed by this tool.
44
+ def subcommands
45
+ ["log", "diff", "commit", "checkout", "branch", "show"]
46
+ end
46
47
  end
47
48
  end
@@ -5,31 +5,24 @@ class LLM::Tool
5
5
  # The {LLM::Tool::Mkdir LLM::Tool::Mkdir} class implements
6
6
  # a tool that can create a tree of new directories.
7
7
  class Mkdir < self
8
+ require_relative "exec"
9
+
8
10
  name "mkdir"
9
- description "create a new directory"
11
+ description "Create a new directory\n" \
12
+ "This command (mkdir) is spawned without a shell"
10
13
  parameter :path, String, "the path to the directory"
14
+ parameter :max_bytes, Integer, "max number of bytes to emit"
15
+ required %i[path]
16
+ defaults max_bytes: -> { Exec.max_bytes }
11
17
 
12
18
  ##
13
19
  # @param [String] path
14
20
  # @return [Hash]
15
- def call(path:)
16
- command = spawn(path:)
17
- {ok: command.success?, stdout: command.stdout, stderr: command.stderr}
18
- rescue LLM::Interrupt
19
- command.kill! if command&.running?
20
- raise
21
+ def call(path:, max_bytes: Exec.max_bytes)
22
+ Exec.new.call(
23
+ arguments: ["mkdir", "-p", path],
24
+ max_bytes:
25
+ )
21
26
  end
22
-
23
- private
24
-
25
- def spawn(path:)
26
- Command
27
- .new("mkdir")
28
- .argv("-p", path)
29
- .spawn
30
- end
31
-
32
- LLM.require "test-cmd.rb", "~> 1.1"
33
- Command = Test::Cmd
34
27
  end
35
28
  end