Skip to content

Commit e36cbbf

Browse files
committed
Validate tool args upfront with execute signature
1 parent 6245c0c commit e36cbbf

1 file changed

Lines changed: 41 additions & 7 deletions

File tree

lib/ruby_llm/tool.rb

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,13 @@ def params_schema
100100

101101
def call(args)
102102
normalized_args = normalize_args(args)
103+
validation_error = validate_keyword_arguments(normalized_args)
104+
return { error: "Invalid tool arguments: #{validation_error}" } if validation_error
105+
103106
RubyLLM.logger.debug { "Tool #{name} called with: #{normalized_args.inspect}" }
104107
result = execute(**normalized_args)
105108
RubyLLM.logger.debug { "Tool #{name} returned: #{result.inspect}" }
106109
result
107-
rescue ArgumentError => e
108-
raise e unless keyword_argument_error?(e)
109-
110-
{ error: "Invalid tool arguments: #{e.message}" }
111110
end
112111

113112
def execute(...)
@@ -127,9 +126,44 @@ def normalize_args(args)
127126
{}
128127
end
129128

130-
def keyword_argument_error?(error)
131-
message = error.message.to_s
132-
message.include?('unknown keyword') || message.include?('missing keyword')
129+
def validate_keyword_arguments(arguments)
130+
required_keywords, optional_keywords, accepts_extra_keywords = execute_keyword_signature
131+
132+
return nil if required_keywords.empty? && optional_keywords.empty?
133+
134+
argument_keys = arguments.keys
135+
missing_keyword = first_missing_keyword(required_keywords, argument_keys)
136+
return "missing keyword: #{keyword_label(missing_keyword)}" if missing_keyword
137+
return nil if accepts_extra_keywords
138+
139+
allowed_keywords = required_keywords + optional_keywords
140+
unknown_keyword = first_unknown_keyword(argument_keys, allowed_keywords)
141+
return "unknown keyword: #{keyword_label(unknown_keyword)}" if unknown_keyword
142+
143+
nil
144+
end
145+
146+
def execute_keyword_signature
147+
keyword_signature = method(:execute).parameters
148+
required_keywords = keyword_signature.filter_map { |kind, name| name if kind == :keyreq }
149+
optional_keywords = keyword_signature.filter_map { |kind, name| name if kind == :key }
150+
accepts_extra_keywords = keyword_signature.any? { |kind, _| kind == :keyrest }
151+
152+
[required_keywords, optional_keywords, accepts_extra_keywords]
153+
end
154+
155+
def first_missing_keyword(required_keywords, argument_keys)
156+
(required_keywords - argument_keys).first
157+
end
158+
159+
def first_unknown_keyword(argument_keys, allowed_keywords)
160+
(argument_keys - allowed_keywords).first
161+
end
162+
163+
def keyword_label(keyword)
164+
return keyword.to_s if RUBY_ENGINE == 'jruby'
165+
166+
":#{keyword}"
133167
end
134168

135169
# Wraps schema handling for tool parameters, supporting JSON Schema hashes,

0 commit comments

Comments
 (0)