shared_tools 0.3.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/shared_tools/tools/browser_tool.rb +8 -2
- data/lib/shared_tools/tools/clipboard_tool.rb +194 -0
- data/lib/shared_tools/tools/computer_tool.rb +8 -2
- data/lib/shared_tools/tools/cron_tool.rb +474 -0
- data/lib/shared_tools/tools/current_date_time_tool.rb +154 -0
- data/lib/shared_tools/tools/database_tool.rb +8 -3
- data/lib/shared_tools/tools/dns_tool.rb +356 -0
- data/lib/shared_tools/tools/system_info_tool.rb +417 -0
- data/lib/shared_tools/version.rb +1 -1
- data/lib/shared_tools.rb +54 -13
- metadata +7 -2
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm/tool'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# A tool for parsing, validating, and explaining cron expressions.
|
|
9
|
+
# Supports standard 5-field cron format (minute, hour, day of month, month, day of week).
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::CronTool.new
|
|
13
|
+
# result = tool.execute(action: 'parse', expression: '0 9 * * 1-5')
|
|
14
|
+
# puts result[:description] # "At 09:00, Monday through Friday"
|
|
15
|
+
class CronTool < RubyLLM::Tool
|
|
16
|
+
def self.name = 'cron'
|
|
17
|
+
|
|
18
|
+
description <<~'DESCRIPTION'
|
|
19
|
+
Parse, validate, and explain cron expressions.
|
|
20
|
+
|
|
21
|
+
Supports standard 5-field cron format:
|
|
22
|
+
- minute (0-59)
|
|
23
|
+
- hour (0-23)
|
|
24
|
+
- day of month (1-31)
|
|
25
|
+
- month (1-12 or JAN-DEC)
|
|
26
|
+
- day of week (0-7 or SUN-SAT, where 0 and 7 are Sunday)
|
|
27
|
+
|
|
28
|
+
Special characters supported:
|
|
29
|
+
- * (any value)
|
|
30
|
+
- , (value list separator)
|
|
31
|
+
- - (range of values)
|
|
32
|
+
- / (step values)
|
|
33
|
+
|
|
34
|
+
Actions:
|
|
35
|
+
- 'parse': Parse and explain a cron expression
|
|
36
|
+
- 'validate': Check if a cron expression is valid
|
|
37
|
+
- 'next': Calculate the next N execution times
|
|
38
|
+
- 'generate': Generate a cron expression from a description
|
|
39
|
+
|
|
40
|
+
Example usage:
|
|
41
|
+
tool = SharedTools::Tools::CronTool.new
|
|
42
|
+
|
|
43
|
+
# Parse and explain
|
|
44
|
+
tool.execute(action: 'parse', expression: '0 9 * * 1-5')
|
|
45
|
+
# => "At 09:00, Monday through Friday"
|
|
46
|
+
|
|
47
|
+
# Validate
|
|
48
|
+
tool.execute(action: 'validate', expression: '0 9 * * *')
|
|
49
|
+
# => { valid: true }
|
|
50
|
+
|
|
51
|
+
# Get next execution times
|
|
52
|
+
tool.execute(action: 'next', expression: '0 * * * *', count: 5)
|
|
53
|
+
|
|
54
|
+
# Generate from description
|
|
55
|
+
tool.execute(action: 'generate', description: 'every day at 9am')
|
|
56
|
+
DESCRIPTION
|
|
57
|
+
|
|
58
|
+
params do
|
|
59
|
+
string :action, description: <<~DESC.strip
|
|
60
|
+
The action to perform:
|
|
61
|
+
- 'parse': Parse and explain a cron expression
|
|
62
|
+
- 'validate': Check if expression is valid
|
|
63
|
+
- 'next': Calculate next execution times
|
|
64
|
+
- 'generate': Generate expression from description
|
|
65
|
+
DESC
|
|
66
|
+
|
|
67
|
+
string :expression, description: <<~DESC.strip, required: false
|
|
68
|
+
The cron expression to parse, validate, or calculate.
|
|
69
|
+
Required for 'parse', 'validate', and 'next' actions.
|
|
70
|
+
DESC
|
|
71
|
+
|
|
72
|
+
string :description, description: <<~DESC.strip, required: false
|
|
73
|
+
Human-readable schedule description for 'generate' action.
|
|
74
|
+
Examples: 'every day at 9am', 'every monday at noon', 'every 5 minutes'
|
|
75
|
+
DESC
|
|
76
|
+
|
|
77
|
+
integer :count, description: <<~DESC.strip, required: false
|
|
78
|
+
Number of next execution times to return for 'next' action.
|
|
79
|
+
Default: 5, Maximum: 20
|
|
80
|
+
DESC
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
DAYS_OF_WEEK = {
|
|
84
|
+
'SUN' => 0, 'MON' => 1, 'TUE' => 2, 'WED' => 3,
|
|
85
|
+
'THU' => 4, 'FRI' => 5, 'SAT' => 6
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
MONTHS = {
|
|
89
|
+
'JAN' => 1, 'FEB' => 2, 'MAR' => 3, 'APR' => 4,
|
|
90
|
+
'MAY' => 5, 'JUN' => 6, 'JUL' => 7, 'AUG' => 8,
|
|
91
|
+
'SEP' => 9, 'OCT' => 10, 'NOV' => 11, 'DEC' => 12
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
# @param logger [Logger] optional logger
|
|
95
|
+
def initialize(logger: nil)
|
|
96
|
+
@logger = logger || RubyLLM.logger
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Execute cron action
|
|
100
|
+
#
|
|
101
|
+
# @param action [String] action to perform
|
|
102
|
+
# @param expression [String, nil] cron expression
|
|
103
|
+
# @param description [String, nil] schedule description for generate
|
|
104
|
+
# @param count [Integer, nil] number of next executions
|
|
105
|
+
# @return [Hash] result
|
|
106
|
+
def execute(action:, expression: nil, description: nil, count: nil)
|
|
107
|
+
@logger.info("CronTool#execute action=#{action.inspect}")
|
|
108
|
+
|
|
109
|
+
case action.to_s.downcase
|
|
110
|
+
when 'parse'
|
|
111
|
+
parse_expression(expression)
|
|
112
|
+
when 'validate'
|
|
113
|
+
validate_expression(expression)
|
|
114
|
+
when 'next'
|
|
115
|
+
next_executions(expression, count || 5)
|
|
116
|
+
when 'generate'
|
|
117
|
+
generate_expression(description)
|
|
118
|
+
else
|
|
119
|
+
{
|
|
120
|
+
success: false,
|
|
121
|
+
error: "Unknown action: #{action}. Valid actions are: parse, validate, next, generate"
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
rescue => e
|
|
125
|
+
@logger.error("CronTool error: #{e.message}")
|
|
126
|
+
{
|
|
127
|
+
success: false,
|
|
128
|
+
error: e.message
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def parse_expression(expression)
|
|
135
|
+
return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
|
|
136
|
+
|
|
137
|
+
validation = validate_expression(expression)
|
|
138
|
+
return validation unless validation[:valid]
|
|
139
|
+
|
|
140
|
+
parts = expression.strip.split(/\s+/)
|
|
141
|
+
minute, hour, dom, month, dow = parts
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
success: true,
|
|
145
|
+
expression: expression,
|
|
146
|
+
fields: {
|
|
147
|
+
minute: minute,
|
|
148
|
+
hour: hour,
|
|
149
|
+
day_of_month: dom,
|
|
150
|
+
month: month,
|
|
151
|
+
day_of_week: dow
|
|
152
|
+
},
|
|
153
|
+
description: build_description(minute, hour, dom, month, dow),
|
|
154
|
+
expanded: {
|
|
155
|
+
minutes: expand_field(minute, 0, 59),
|
|
156
|
+
hours: expand_field(hour, 0, 23),
|
|
157
|
+
days_of_month: expand_field(dom, 1, 31),
|
|
158
|
+
months: expand_field(month, 1, 12),
|
|
159
|
+
days_of_week: expand_field(dow, 0, 6)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def validate_expression(expression)
|
|
165
|
+
return { valid: false, error: "Expression is required" } if expression.nil? || expression.empty?
|
|
166
|
+
|
|
167
|
+
parts = expression.strip.split(/\s+/)
|
|
168
|
+
|
|
169
|
+
unless parts.length == 5
|
|
170
|
+
return {
|
|
171
|
+
valid: false,
|
|
172
|
+
error: "Invalid cron expression: expected 5 fields, got #{parts.length}"
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
minute, hour, dom, month, dow = parts
|
|
177
|
+
|
|
178
|
+
errors = []
|
|
179
|
+
errors << validate_field(minute, 0, 59, 'minute')
|
|
180
|
+
errors << validate_field(hour, 0, 23, 'hour')
|
|
181
|
+
errors << validate_field(dom, 1, 31, 'day of month')
|
|
182
|
+
errors << validate_field(month, 1, 12, 'month')
|
|
183
|
+
errors << validate_field(dow, 0, 7, 'day of week')
|
|
184
|
+
|
|
185
|
+
errors.compact!
|
|
186
|
+
|
|
187
|
+
if errors.empty?
|
|
188
|
+
{ valid: true, expression: expression }
|
|
189
|
+
else
|
|
190
|
+
{ valid: false, errors: errors }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def next_executions(expression, count)
|
|
195
|
+
return { success: false, error: "Expression is required" } if expression.nil? || expression.empty?
|
|
196
|
+
|
|
197
|
+
validation = validate_expression(expression)
|
|
198
|
+
return { success: false, error: validation[:errors]&.join(', ') || validation[:error] } unless validation[:valid]
|
|
199
|
+
|
|
200
|
+
count = [[count.to_i, 1].max, 20].min
|
|
201
|
+
|
|
202
|
+
parts = expression.strip.split(/\s+/)
|
|
203
|
+
minute_spec, hour_spec, dom_spec, month_spec, dow_spec = parts
|
|
204
|
+
|
|
205
|
+
minutes = expand_field(minute_spec, 0, 59)
|
|
206
|
+
hours = expand_field(hour_spec, 0, 23)
|
|
207
|
+
doms = expand_field(dom_spec, 1, 31)
|
|
208
|
+
months = expand_field(month_spec, 1, 12)
|
|
209
|
+
dows = expand_field(dow_spec, 0, 6)
|
|
210
|
+
|
|
211
|
+
executions = []
|
|
212
|
+
current = Time.now + 60 # Start from next minute
|
|
213
|
+
|
|
214
|
+
max_iterations = 366 * 24 * 60 # One year of minutes max
|
|
215
|
+
iterations = 0
|
|
216
|
+
|
|
217
|
+
while executions.length < count && iterations < max_iterations
|
|
218
|
+
iterations += 1
|
|
219
|
+
|
|
220
|
+
if matches_cron?(current, minutes, hours, doms, months, dows)
|
|
221
|
+
executions << current.strftime('%Y-%m-%d %H:%M')
|
|
222
|
+
current += 60
|
|
223
|
+
else
|
|
224
|
+
current += 60
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
{
|
|
229
|
+
success: true,
|
|
230
|
+
expression: expression,
|
|
231
|
+
count: executions.length,
|
|
232
|
+
next_executions: executions
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def generate_expression(description)
|
|
237
|
+
return { success: false, error: "Description is required" } if description.nil? || description.empty?
|
|
238
|
+
|
|
239
|
+
desc = description.downcase.strip
|
|
240
|
+
expression = nil
|
|
241
|
+
|
|
242
|
+
# Common patterns - more specific patterns must come before general ones
|
|
243
|
+
case desc
|
|
244
|
+
when /every\s+minute/
|
|
245
|
+
expression = '* * * * *'
|
|
246
|
+
when /every\s+(\d+)\s+minutes?/
|
|
247
|
+
interval = $1.to_i
|
|
248
|
+
expression = "*/#{interval} * * * *"
|
|
249
|
+
when /every\s+hour/
|
|
250
|
+
expression = '0 * * * *'
|
|
251
|
+
when /every\s+(\d+)\s+hours?/
|
|
252
|
+
interval = $1.to_i
|
|
253
|
+
expression = "0 */#{interval} * * *"
|
|
254
|
+
when /every\s+day\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
255
|
+
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
256
|
+
hour += 12 if ampm == 'pm' && hour != 12
|
|
257
|
+
hour = 0 if ampm == 'am' && hour == 12
|
|
258
|
+
expression = "#{min} #{hour} * * *"
|
|
259
|
+
when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
260
|
+
day_name = $1.upcase[0..2]
|
|
261
|
+
day_num = DAYS_OF_WEEK[day_name]
|
|
262
|
+
hour, min, ampm = $2.to_i, ($3 || '0').to_i, $4
|
|
263
|
+
hour += 12 if ampm == 'pm' && hour != 12
|
|
264
|
+
hour = 0 if ampm == 'am' && hour == 12
|
|
265
|
+
expression = "#{min} #{hour} * * #{day_num}"
|
|
266
|
+
when /every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)/
|
|
267
|
+
day_name = $1.upcase[0..2]
|
|
268
|
+
day_num = DAYS_OF_WEEK[day_name]
|
|
269
|
+
expression = "0 0 * * #{day_num}"
|
|
270
|
+
when /weekdays?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
271
|
+
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
272
|
+
hour += 12 if ampm == 'pm' && hour != 12
|
|
273
|
+
hour = 0 if ampm == 'am' && hour == 12
|
|
274
|
+
expression = "#{min} #{hour} * * 1-5"
|
|
275
|
+
when /weekends?\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/
|
|
276
|
+
hour, min, ampm = $1.to_i, ($2 || '0').to_i, $3
|
|
277
|
+
hour += 12 if ampm == 'pm' && hour != 12
|
|
278
|
+
hour = 0 if ampm == 'am' && hour == 12
|
|
279
|
+
expression = "#{min} #{hour} * * 0,6"
|
|
280
|
+
when /at\s+noon/
|
|
281
|
+
expression = '0 12 * * *'
|
|
282
|
+
when /at\s+midnight/
|
|
283
|
+
expression = '0 0 * * *'
|
|
284
|
+
when /monthly|first\s+of\s+(the\s+)?month/
|
|
285
|
+
expression = '0 0 1 * *'
|
|
286
|
+
when /yearly|annually/
|
|
287
|
+
expression = '0 0 1 1 *'
|
|
288
|
+
when /hourly\s+at\s+:?(\d{2})/
|
|
289
|
+
min = $1.to_i
|
|
290
|
+
expression = "#{min} * * * *"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
if expression
|
|
294
|
+
parsed = parse_expression(expression)
|
|
295
|
+
{
|
|
296
|
+
success: true,
|
|
297
|
+
description: description,
|
|
298
|
+
expression: expression,
|
|
299
|
+
explanation: parsed[:description]
|
|
300
|
+
}
|
|
301
|
+
else
|
|
302
|
+
{
|
|
303
|
+
success: false,
|
|
304
|
+
error: "Could not parse description: '#{description}'. Try formats like 'every day at 9am', 'every monday at noon', 'every 5 minutes'"
|
|
305
|
+
}
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def validate_field(field, min, max, name)
|
|
310
|
+
return nil if field == '*'
|
|
311
|
+
|
|
312
|
+
# Handle step values
|
|
313
|
+
if field.include?('/')
|
|
314
|
+
base, step = field.split('/')
|
|
315
|
+
return "Invalid step value in #{name}" unless step =~ /^\d+$/ && step.to_i > 0
|
|
316
|
+
return validate_field(base, min, max, name) unless base == '*'
|
|
317
|
+
return nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Handle ranges
|
|
321
|
+
if field.include?('-')
|
|
322
|
+
parts = field.split('-')
|
|
323
|
+
return "Invalid range in #{name}" unless parts.length == 2
|
|
324
|
+
start_val = normalize_value(parts[0], name)
|
|
325
|
+
end_val = normalize_value(parts[1], name)
|
|
326
|
+
return "Invalid range values in #{name}" if start_val.nil? || end_val.nil?
|
|
327
|
+
return "Range out of bounds in #{name}" if start_val < min || end_val > max || start_val > end_val
|
|
328
|
+
return nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Handle lists
|
|
332
|
+
if field.include?(',')
|
|
333
|
+
field.split(',').each do |val|
|
|
334
|
+
err = validate_field(val.strip, min, max, name)
|
|
335
|
+
return err if err
|
|
336
|
+
end
|
|
337
|
+
return nil
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Single value
|
|
341
|
+
val = normalize_value(field, name)
|
|
342
|
+
return "Invalid value '#{field}' in #{name}" if val.nil?
|
|
343
|
+
return "Value #{val} out of bounds (#{min}-#{max}) in #{name}" if val < min || val > max
|
|
344
|
+
|
|
345
|
+
nil
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def normalize_value(val, field_name)
|
|
349
|
+
return nil if val.nil? || val.empty?
|
|
350
|
+
|
|
351
|
+
# Handle day of week names
|
|
352
|
+
if %w[day\ of\ week].include?(field_name)
|
|
353
|
+
return DAYS_OF_WEEK[val.upcase] if DAYS_OF_WEEK.key?(val.upcase)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Handle month names
|
|
357
|
+
if field_name == 'month'
|
|
358
|
+
return MONTHS[val.upcase] if MONTHS.key?(val.upcase)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
return val.to_i if val =~ /^\d+$/
|
|
362
|
+
|
|
363
|
+
nil
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def expand_field(field, min, max)
|
|
367
|
+
return (min..max).to_a if field == '*'
|
|
368
|
+
|
|
369
|
+
if field.include?('/')
|
|
370
|
+
base, step = field.split('/')
|
|
371
|
+
step = step.to_i
|
|
372
|
+
start_vals = base == '*' ? (min..max).to_a : expand_field(base, min, max)
|
|
373
|
+
return start_vals.select { |v| (v - start_vals.first) % step == 0 }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
if field.include?(',')
|
|
377
|
+
return field.split(',').flat_map { |f| expand_field(f.strip, min, max) }.sort.uniq
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
if field.include?('-')
|
|
381
|
+
start_val, end_val = field.split('-').map { |v| normalize_value(v, '') || v.to_i }
|
|
382
|
+
return (start_val..end_val).to_a
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
[normalize_value(field, '') || field.to_i]
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def matches_cron?(time, minutes, hours, doms, months, dows)
|
|
389
|
+
return false unless minutes.include?(time.min)
|
|
390
|
+
return false unless hours.include?(time.hour)
|
|
391
|
+
return false unless months.include?(time.month)
|
|
392
|
+
|
|
393
|
+
# Day matching: either day of month OR day of week must match
|
|
394
|
+
# (unless both are specified as specific values)
|
|
395
|
+
dom_match = doms.include?(time.day)
|
|
396
|
+
dow_match = dows.include?(time.wday)
|
|
397
|
+
|
|
398
|
+
dom_match || dow_match
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def build_description(minute, hour, dom, month, dow)
|
|
402
|
+
parts = []
|
|
403
|
+
|
|
404
|
+
# Time part
|
|
405
|
+
time_desc = describe_time(minute, hour)
|
|
406
|
+
parts << time_desc if time_desc
|
|
407
|
+
|
|
408
|
+
# Day of month part
|
|
409
|
+
if dom != '*'
|
|
410
|
+
parts << "on day #{describe_field(dom)} of the month"
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Month part
|
|
414
|
+
if month != '*'
|
|
415
|
+
month_names = expand_field(month, 1, 12).map { |m| Date::MONTHNAMES[m] }
|
|
416
|
+
parts << "in #{month_names.join(', ')}"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Day of week part
|
|
420
|
+
if dow != '*'
|
|
421
|
+
day_names = expand_field(dow, 0, 6).map { |d| Date::DAYNAMES[d] }
|
|
422
|
+
if day_names == %w[Monday Tuesday Wednesday Thursday Friday]
|
|
423
|
+
parts << "on weekdays"
|
|
424
|
+
elsif day_names == %w[Sunday Saturday]
|
|
425
|
+
parts << "on weekends"
|
|
426
|
+
else
|
|
427
|
+
parts << "on #{day_names.join(', ')}"
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
parts.empty? ? "Every minute" : parts.join(', ')
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def describe_time(minute, hour)
|
|
435
|
+
if minute == '*' && hour == '*'
|
|
436
|
+
return nil # Every minute, handled by default
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
if minute == '*'
|
|
440
|
+
hours = expand_field(hour, 0, 23)
|
|
441
|
+
return "Every minute during hour(s) #{hours.join(', ')}"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
if hour == '*'
|
|
445
|
+
minutes = expand_field(minute, 0, 59)
|
|
446
|
+
if minute.include?('/')
|
|
447
|
+
return "Every #{minute.split('/').last} minutes"
|
|
448
|
+
end
|
|
449
|
+
return "At minute #{minutes.join(', ')} of every hour"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
hours = expand_field(hour, 0, 23)
|
|
453
|
+
minutes = expand_field(minute, 0, 59)
|
|
454
|
+
|
|
455
|
+
times = hours.flat_map do |h|
|
|
456
|
+
minutes.map { |m| format('%02d:%02d', h, m) }
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
"At #{times.join(', ')}"
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def describe_field(field)
|
|
463
|
+
return 'every' if field == '*'
|
|
464
|
+
|
|
465
|
+
if field.include?('/')
|
|
466
|
+
base, step = field.split('/')
|
|
467
|
+
return "every #{step}#{base == '*' ? '' : " starting at #{base}"}"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
field
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ruby_llm/tool'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module SharedTools
|
|
7
|
+
module Tools
|
|
8
|
+
# A tool that returns the current date, time, and timezone information.
|
|
9
|
+
# Useful for AI assistants that need to know the current time context.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# tool = SharedTools::Tools::CurrentDateTimeTool.new
|
|
13
|
+
# result = tool.execute
|
|
14
|
+
# puts result[:date] # "2025-12-17"
|
|
15
|
+
# puts result[:time] # "13:45:30"
|
|
16
|
+
# puts result[:timezone] # "America/Chicago"
|
|
17
|
+
class CurrentDateTimeTool < RubyLLM::Tool
|
|
18
|
+
def self.name = 'current_date_time'
|
|
19
|
+
|
|
20
|
+
description <<~'DESCRIPTION'
|
|
21
|
+
Returns the current date, time, and timezone information from the system.
|
|
22
|
+
This tool provides accurate temporal context for AI assistants that need
|
|
23
|
+
to reason about time-sensitive information or schedule-related queries.
|
|
24
|
+
|
|
25
|
+
The tool returns:
|
|
26
|
+
- Current date in ISO 8601 format (YYYY-MM-DD)
|
|
27
|
+
- Current time in 24-hour format (HH:MM:SS)
|
|
28
|
+
- Current timezone name and UTC offset
|
|
29
|
+
- Unix timestamp for precise time calculations
|
|
30
|
+
- Day of week and week number for scheduling context
|
|
31
|
+
|
|
32
|
+
Example usage:
|
|
33
|
+
tool = SharedTools::Tools::CurrentDateTimeTool.new
|
|
34
|
+
result = tool.execute
|
|
35
|
+
puts "Today is #{result[:day_of_week]}, #{result[:date]}"
|
|
36
|
+
puts "Current time: #{result[:time]} #{result[:timezone]}"
|
|
37
|
+
DESCRIPTION
|
|
38
|
+
|
|
39
|
+
params do
|
|
40
|
+
string :format, description: <<~DESC.strip, required: false
|
|
41
|
+
Output format preference. Options:
|
|
42
|
+
- 'full' (default): Returns all date/time information
|
|
43
|
+
- 'date_only': Returns only date-related fields
|
|
44
|
+
- 'time_only': Returns only time-related fields
|
|
45
|
+
- 'iso8601': Returns a single ISO 8601 formatted datetime string
|
|
46
|
+
DESC
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @param logger [Logger] optional logger
|
|
50
|
+
def initialize(logger: nil)
|
|
51
|
+
@logger = logger || RubyLLM.logger
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Execute the date/time query
|
|
55
|
+
#
|
|
56
|
+
# @param format [String] Output format ('full', 'date_only', 'time_only', 'iso8601')
|
|
57
|
+
# @return [Hash] Current date/time information
|
|
58
|
+
def execute(format: 'full')
|
|
59
|
+
@logger.info("DateTimeTool#execute format=#{format.inspect}")
|
|
60
|
+
|
|
61
|
+
now = Time.now
|
|
62
|
+
|
|
63
|
+
case format.to_s.downcase
|
|
64
|
+
when 'date_only'
|
|
65
|
+
date_only_response(now)
|
|
66
|
+
when 'time_only'
|
|
67
|
+
time_only_response(now)
|
|
68
|
+
when 'iso8601'
|
|
69
|
+
iso8601_response(now)
|
|
70
|
+
else
|
|
71
|
+
full_response(now)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Full response with all date/time information
|
|
78
|
+
def full_response(now)
|
|
79
|
+
{
|
|
80
|
+
success: true,
|
|
81
|
+
date: now.strftime('%Y-%m-%d'),
|
|
82
|
+
time: now.strftime('%H:%M:%S'),
|
|
83
|
+
datetime: now.iso8601,
|
|
84
|
+
timezone: now.zone,
|
|
85
|
+
timezone_name: timezone_name(now),
|
|
86
|
+
utc_offset: formatted_utc_offset(now),
|
|
87
|
+
unix_timestamp: now.to_i,
|
|
88
|
+
day_of_week: now.strftime('%A'),
|
|
89
|
+
day_of_year: now.yday,
|
|
90
|
+
week_number: now.strftime('%V').to_i,
|
|
91
|
+
is_dst: now.dst?,
|
|
92
|
+
quarter: ((now.month - 1) / 3) + 1
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Date-only response
|
|
97
|
+
def date_only_response(now)
|
|
98
|
+
{
|
|
99
|
+
success: true,
|
|
100
|
+
date: now.strftime('%Y-%m-%d'),
|
|
101
|
+
year: now.year,
|
|
102
|
+
month: now.month,
|
|
103
|
+
month_name: now.strftime('%B'),
|
|
104
|
+
day: now.day,
|
|
105
|
+
day_of_week: now.strftime('%A'),
|
|
106
|
+
day_of_year: now.yday,
|
|
107
|
+
week_number: now.strftime('%V').to_i,
|
|
108
|
+
quarter: ((now.month - 1) / 3) + 1
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Time-only response
|
|
113
|
+
def time_only_response(now)
|
|
114
|
+
{
|
|
115
|
+
success: true,
|
|
116
|
+
time: now.strftime('%H:%M:%S'),
|
|
117
|
+
time_12h: now.strftime('%I:%M:%S %p'),
|
|
118
|
+
hour: now.hour,
|
|
119
|
+
minute: now.min,
|
|
120
|
+
second: now.sec,
|
|
121
|
+
timezone: now.zone,
|
|
122
|
+
timezone_name: timezone_name(now),
|
|
123
|
+
utc_offset: formatted_utc_offset(now),
|
|
124
|
+
unix_timestamp: now.to_i,
|
|
125
|
+
is_dst: now.dst?
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# ISO 8601 formatted response
|
|
130
|
+
def iso8601_response(now)
|
|
131
|
+
{
|
|
132
|
+
success: true,
|
|
133
|
+
datetime: now.iso8601,
|
|
134
|
+
utc: now.utc.iso8601
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get the IANA timezone name if available
|
|
139
|
+
def timezone_name(time)
|
|
140
|
+
# Try to get IANA timezone from TZ environment variable
|
|
141
|
+
ENV['TZ'] || time.zone
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Format UTC offset as "+HH:MM" or "-HH:MM"
|
|
145
|
+
def formatted_utc_offset(time)
|
|
146
|
+
offset_seconds = time.utc_offset
|
|
147
|
+
sign = offset_seconds >= 0 ? '+' : '-'
|
|
148
|
+
hours, remainder = offset_seconds.abs.divmod(3600)
|
|
149
|
+
minutes = remainder / 60
|
|
150
|
+
format('%s%02d:%02d', sign, hours, minutes)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -62,14 +62,17 @@ module SharedTools
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
# @param driver [SharedTools::Tools::Database::BaseDriver]
|
|
65
|
+
# @param driver [SharedTools::Tools::Database::BaseDriver] database driver (SqliteDriver, PostgresDriver, etc.)
|
|
66
|
+
# Required for execute, but optional for instantiation to support RubyLLM tool discovery
|
|
66
67
|
# @param logger [Logger] optional logger
|
|
67
|
-
def initialize(driver
|
|
68
|
-
raise ArgumentError, "driver is required for DatabaseTool" if driver.nil?
|
|
68
|
+
def initialize(driver: nil, logger: nil)
|
|
69
69
|
@driver = driver
|
|
70
70
|
@logger = logger || RubyLLM.logger
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
+
# Set driver after instantiation (useful when tool is discovered by RubyLLM)
|
|
74
|
+
attr_writer :driver
|
|
75
|
+
|
|
73
76
|
# @example
|
|
74
77
|
# tool = SharedTools::Tools::Database::BaseTool.new
|
|
75
78
|
# tool.execute(statements: ["SELECT * FROM people"])
|
|
@@ -78,6 +81,8 @@ module SharedTools
|
|
|
78
81
|
#
|
|
79
82
|
# @return [Array<Hash>]
|
|
80
83
|
def execute(statements:)
|
|
84
|
+
raise ArgumentError, "driver is required for DatabaseTool#execute. Set via initialize(driver:) or tool.driver=" if @driver.nil?
|
|
85
|
+
|
|
81
86
|
[].tap do |executions|
|
|
82
87
|
statements.map do |statement|
|
|
83
88
|
execution = perform(statement:).merge(statement:)
|