foobara-agent 0.0.7 → 0.0.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 968399c2d338c8f069712d0ad9437b928fe48bc7356e88fd86b9ff6fb7ae235f
4
- data.tar.gz: 8483b856faf2f559985550cc631764121797f88963c0baf428b7f16646bd8b59
3
+ metadata.gz: 12e8facdeb95a000d81ec0ba8a133affa1ea66e470115e978bdc81f05bc51855
4
+ data.tar.gz: 294064df166d152c037db13263863b8ccfe32f064a78e77f6f5fe59af0be3829
5
5
  SHA512:
6
- metadata.gz: 79be2912847cb407d1ad65166313c8b799b981a2b0da6f8c8e3f8132049c1eb67ec56a9307fdf887f8e659299883f0da4517762abc885fdf8b69949f869f54de
7
- data.tar.gz: 169c02a8070d5feb4d307b41ff757294ca5a2e3b810ac0766cece386a357db2cf85075ae9ecb1f5ee2e0b1ec92c855eb202ee4166102a88341b8e5b4268eeb92
6
+ metadata.gz: 2c7fcaf044ba9bc821dcc42f9b0fb44b412fb42f9ef77dd4b7f3ed13cb8293cdb60d448d136e4e0567d104ecb022a00accfd05dbcf955e38185884d34e59668e
7
+ data.tar.gz: 380d9ae749b6a942b4e33b112f5aae35fd9600fa9ffcaf462fc0a35122354aae0658378190a4b9db3c0d65d2a12c9d49305b1d03a2881ce504d6d268cf039255
data/CHANGELOG.md CHANGED
@@ -1,6 +1,14 @@
1
+ ## [0.0.8] - 2025-06-27
2
+
3
+ - Improve what is logged and its formatting when verbose
4
+ - Improve/experiment with what is stored in the command log
5
+ - Experiment with Atoms instead of Aggregates
6
+ - Add max_llm_calls_per_minute option
7
+
1
8
  ## [0.0.7] - 2025-06-23
2
9
 
3
- -
10
+ - Improvements to result type handling
11
+ - Eliminate DescribeType command
4
12
 
5
13
  ## [0.0.6] - 2025-06-19
6
14
 
data/lib/foobara/agent.rb CHANGED
@@ -12,7 +12,6 @@ module Foobara
12
12
  def reset_all
13
13
  [
14
14
  DetermineInputsForNextCommand,
15
- DetermineNextCommand,
16
15
  NotifyUserThatCurrentGoalHasBeenAccomplished
17
16
  ].each do |command_class|
18
17
  command_class.clear_subclass_cache
@@ -3,6 +3,9 @@ require_relative "list_commands"
3
3
  module Foobara
4
4
  class Agent < CommandConnector
5
5
  class AccomplishGoal < Foobara::Command
6
+ # Using a const here so we can stub it in the test suite to speed things up
7
+ SECONDS_PER_MINUTE = 60
8
+
6
9
  possible_error :gave_up, context: { reason: :string }, message: "Gave up."
7
10
  possible_error :too_many_command_calls,
8
11
  context: { maximum_command_calls: :integer }
@@ -26,19 +29,13 @@ module Foobara
26
29
  one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
27
30
  default: "claude-3-7-sonnet-20250219",
28
31
  description: "The model to use for the LLM"
29
- log_successful_determine_command_and_inputs_outcomes(
30
- :boolean,
31
- default: true,
32
- description: "You can experiment with turning this off " \
33
- "if you want to see what happens if we don't log " \
34
- "successful command/input selection outcomes"
35
- )
36
32
  choose_next_command_and_next_inputs_separately :boolean,
37
33
  default: false,
38
34
  description:
39
35
  "By default, asks for next command and inputs together. " \
40
36
  "You can experiment with getting the separately " \
41
37
  "with this flag if you wish."
38
+ max_llm_calls_per_minute :integer, :allow_nil
42
39
  end
43
40
 
44
41
  result do
@@ -51,13 +48,16 @@ module Foobara
51
48
  def execute
52
49
  build_initial_context_if_necessary
53
50
 
51
+ simulate_describe_list_commands_command
54
52
  simulate_list_commands_run
55
- simulate_describe_command_run_for_all_commands
53
+ # simulate_describe_command_run_for_all_commands
56
54
 
57
55
  until mission_accomplished or given_up
58
56
  increment_command_calls
59
57
  check_if_too_many_calls
60
58
 
59
+ throttle_llm_calls_if_necessary
60
+
61
61
  if choose_next_command_and_next_inputs_separately?
62
62
  determine_next_command_then_inputs_separately
63
63
  else
@@ -75,8 +75,8 @@ module Foobara
75
75
  build_result
76
76
  end
77
77
 
78
- attr_accessor :context, :next_command_name, :next_command_inputs, :mission_accomplished, :given_up,
79
- :next_command_class, :next_command, :command_outcome, :timed_out,
78
+ attr_accessor :context, :next_command_name, :next_command_inputs, :next_command_raw_inputs, :mission_accomplished,
79
+ :given_up, :next_command_class, :next_command, :command_outcome, :timed_out,
80
80
  :final_result, :final_message, :command_response, :delayed_command_name,
81
81
  :command_calls
82
82
 
@@ -86,9 +86,8 @@ module Foobara
86
86
  end
87
87
 
88
88
  def simulate_list_commands_run
89
- return unless context.command_log.empty?
90
-
91
89
  self.next_command_name = ListCommands.full_command_name
90
+ self.next_command_raw_inputs = nil
92
91
  self.next_command_inputs = nil
93
92
  fetch_next_command_class
94
93
 
@@ -96,7 +95,19 @@ module Foobara
96
95
  log_last_command_outcome
97
96
  end
98
97
 
98
+ def simulate_describe_list_commands_command
99
+ self.next_command_name = DescribeCommand.full_command_name
100
+ self.next_command_inputs = { command_name: ListCommands.full_command_name }
101
+ self.next_command_raw_inputs = next_command_inputs
102
+ fetch_next_command_class
103
+
104
+ run_next_command
105
+ log_last_command_outcome
106
+ end
107
+
99
108
  def simulate_describe_command_run_for_all_commands
109
+ # TODO: currently not using this code path. Unclear if it is worth it.
110
+ # :nocov:
100
111
  return if context.command_log.size > 1
101
112
 
102
113
  ListCommands.run!(command_connector: agent)[:user_provided_commands].each do |full_command_name|
@@ -104,24 +115,26 @@ module Foobara
104
115
 
105
116
  self.next_command_name = DescribeCommand.full_command_name
106
117
  self.next_command_inputs = { command_name: full_command_name }
118
+ self.next_command_raw_inputs = next_command_inputs
107
119
  fetch_next_command_class
108
120
 
109
121
  run_next_command
110
122
  log_last_command_outcome
111
123
  end
124
+ # :nocov:
112
125
  end
113
126
 
114
127
  def determine_next_command_and_inputs(retries = 2)
115
128
  inputs_for_determine = {
116
129
  goal:,
117
130
  context:,
118
- llm_model:,
119
- command_class_names: all_command_classes
131
+ llm_model:
120
132
  }
121
133
 
122
134
  determine_command = DetermineNextCommandNameAndInputs.new(inputs_for_determine)
123
135
 
124
136
  outcome = begin
137
+ record_llm_call_timestamp
125
138
  determine_command.run
126
139
  rescue CommandPatternImplementation::Concerns::Result::CouldNotProcessResult => e
127
140
  # :nocov:
@@ -132,6 +145,7 @@ module Foobara
132
145
  if outcome.success?
133
146
  self.next_command_name = outcome.result[:command_name]
134
147
  self.next_command_inputs = outcome.result[:inputs]
148
+ self.next_command_raw_inputs = next_command_inputs
135
149
 
136
150
  outcome = validate_next_command_name
137
151
 
@@ -141,32 +155,25 @@ module Foobara
141
155
  if next_command_has_inputs?
142
156
  outcome = validate_next_command_inputs
143
157
 
144
- if outcome.success?
145
- if log_successful_determine_command_and_inputs_outcomes?
146
- log_command_outcome(
147
- command: determine_command,
148
- inputs: determine_command.inputs.except(:context)
149
- )
150
- end
151
- else
158
+ unless outcome.success?
152
159
  log_command_outcome(
153
- command: determine_command,
154
- inputs: determine_command.inputs.except(:context),
155
- outcome:,
156
- result: outcome.result || determine_command.raw_result
160
+ command_name: next_command_name,
161
+ inputs: next_command_inputs,
162
+ outcome:
157
163
  )
158
164
 
159
165
  determine_next_command_inputs
160
166
  end
161
167
  else
162
168
  self.next_command_inputs = {}
169
+ self.next_command_raw_inputs = next_command_inputs
163
170
  end
164
171
  else
165
172
  log_command_outcome(
166
- command: determine_command,
167
- inputs: determine_command.inputs&.except(:context),
173
+ command_name: next_command_name,
174
+ inputs: next_command_inputs,
168
175
  outcome:,
169
- result: outcome.result || determine_command.raw_result
176
+ result: nil
170
177
  )
171
178
 
172
179
  if retries > 0
@@ -204,10 +211,23 @@ module Foobara
204
211
  end
205
212
 
206
213
  def validate_next_command_name
214
+ if next_command_name.is_a?(::String)
215
+ command_class = agent.transformed_command_from_name(next_command_name)
216
+
217
+ if command_class
218
+ self.next_command_name = command_class.full_command_name
219
+
220
+ return Outcome.success(next_command_name)
221
+ end
222
+ end
223
+
207
224
  outcome = command_name_type.process_value(next_command_name)
208
225
 
209
226
  if outcome.success?
227
+ # TODO: figure out a way to hit this path in the test suite
228
+ # :nocov:
210
229
  self.next_command_name = outcome.result
230
+ # :nocov:
211
231
  end
212
232
 
213
233
  outcome
@@ -231,17 +251,14 @@ module Foobara
231
251
  self.delayed_command_name = nil
232
252
  name
233
253
  else
234
- command_class = DetermineNextCommand.for(
235
- command_class_names: all_command_classes, agent_id: agent_name
236
- )
237
-
238
254
  inputs = { goal:, context: }
239
255
  if llm_model
240
256
  inputs[:llm_model] = llm_model
241
257
  end
242
258
 
243
- command = command_class.new(inputs)
259
+ command = DetermineNextCommand.new(inputs)
244
260
  outcome = begin
261
+ record_llm_call_timestamp
245
262
  command.run
246
263
  rescue CommandPatternImplementation::Concerns::Result::CouldNotProcessResult => e
247
264
  # :nocov:
@@ -250,12 +267,24 @@ module Foobara
250
267
  end
251
268
 
252
269
  if outcome.success?
253
- if log_successful_determine_command_and_inputs_outcomes?
270
+ self.next_command_name = outcome.result
271
+
272
+ outcome = validate_next_command_name
273
+
274
+ unless outcome.success?
275
+ # TODO: figure out a way to hit this path in the test suite or delete it
276
+ # :nocov:
254
277
  log_command_outcome(
255
278
  command:,
256
279
  inputs: command.inputs.except(:context),
257
- outcome:
280
+ outcome:,
281
+ result: outcome.result || command.raw_result
258
282
  )
283
+
284
+ if retries > 0
285
+ return determine_next_command_name(retries - 1)
286
+ end
287
+ # :nocov:
259
288
  end
260
289
  else
261
290
  # TODO: either figure out a way to hit this path in the test suite or delete it
@@ -303,6 +332,7 @@ module Foobara
303
332
 
304
333
  command = command_class.new(inputs)
305
334
  outcome = begin
335
+ record_llm_call_timestamp
306
336
  command.run
307
337
  rescue CommandPatternImplementation::Concerns::Result::CouldNotProcessResult => e
308
338
  # :nocov:
@@ -310,23 +340,15 @@ module Foobara
310
340
  # :nocov:
311
341
  end
312
342
 
313
- if outcome.success?
314
- if log_successful_determine_command_and_inputs_outcomes?
315
- log_command_outcome(
316
- command:,
317
- inputs: command.inputs.except(:context),
318
- outcome:
319
- )
320
- end
321
- else
343
+ unless outcome.success?
322
344
  # TODO: either figure out a way to hit this path in the test suite or delete it
323
345
  # :nocov:
324
346
  log_command_outcome(
325
- command:,
326
- inputs: command.inputs.except(:context),
327
- outcome:,
328
- result: outcome.result || command.raw_result
347
+ command_name: next_command_name,
348
+ inputs: command.raw_result,
349
+ outcome:
329
350
  )
351
+
330
352
  if retries > 0
331
353
  return determine_next_command_inputs(retries - 1)
332
354
  end
@@ -351,7 +373,20 @@ module Foobara
351
373
 
352
374
  def run_next_command
353
375
  if verbose?
354
- (io_out || $stdout).puts "Running #{next_command_name} with #{next_command_inputs}"
376
+ args = if next_command_inputs.nil? || next_command_inputs.empty?
377
+ ""
378
+ else
379
+ s = next_command_inputs.to_s
380
+
381
+ if s =~ /\A\{(.*)}\z/
382
+ "(#{::Regexp.last_match(1)})"
383
+ else
384
+ # :nocov:
385
+ raise "Unexpected next_command_inputs: #{next_command_inputs}"
386
+ # :nocov:
387
+ end
388
+ end
389
+ (io_out || $stdout).puts "#{next_command_name}.run#{args}"
355
390
  end
356
391
 
357
392
  self.command_response = agent.run(
@@ -363,9 +398,7 @@ module Foobara
363
398
  self.command_outcome = command_response.outcome
364
399
 
365
400
  if verbose?
366
- if command_outcome.success?
367
- (io_out || $stdout).puts "Command #{command_response.command.class.full_command_name} succeeded"
368
- else
401
+ unless command_outcome.success?
369
402
  # :nocov:
370
403
  (io_err || $stderr).puts(
371
404
  "Command #{command_response.command.class.full_command_name} failed #{command_outcome.errors_hash}"
@@ -398,8 +431,11 @@ module Foobara
398
431
  def log_command_outcome(command: nil, command_name: nil, inputs: nil, outcome: nil, result: nil)
399
432
  if command
400
433
  command_name ||= command.class.full_command_name
401
- inputs ||= command.inputs
434
+ inputs ||= command.raw_inputs
402
435
  outcome ||= command.outcome
436
+ end
437
+
438
+ if outcome
403
439
  result ||= outcome.result
404
440
  end
405
441
 
@@ -457,10 +493,6 @@ module Foobara
457
493
  type.extends_type?(BuiltinTypes[:attributes]) && type.element_types.empty?
458
494
  end
459
495
 
460
- def log_successful_determine_command_and_inputs_outcomes?
461
- log_successful_determine_command_and_inputs_outcomes
462
- end
463
-
464
496
  def choose_next_command_and_next_inputs_separately?
465
497
  choose_next_command_and_next_inputs_separately
466
498
  end
@@ -472,6 +504,52 @@ module Foobara
472
504
  def verbose?
473
505
  verbose
474
506
  end
507
+
508
+ def llm_call_timestamps
509
+ @llm_call_timestamps ||= []
510
+ end
511
+
512
+ attr_writer :llm_call_timestamps
513
+
514
+ def record_llm_call_timestamp
515
+ llm_call_timestamps.unshift(Time.now)
516
+ end
517
+
518
+ def llm_calls_in_last_minute
519
+ llm_call_timestamps.select { |t| t > (Time.now - 60) }
520
+ end
521
+
522
+ def llm_call_count_in_last_minute
523
+ llm_calls_in_last_minute.size
524
+ end
525
+
526
+ def time_until_llm_call_count_in_last_minute_changes
527
+ calls = llm_calls_in_last_minute
528
+
529
+ first_to_expire = calls.first
530
+
531
+ if first_to_expire
532
+ (first_to_expire + SECONDS_PER_MINUTE) - Time.now
533
+ else
534
+ # TODO: figure out how to test this code path
535
+ # :nocov:
536
+ 0
537
+ # :nocov:
538
+ end
539
+ end
540
+
541
+ def throttle_llm_calls_if_necessary
542
+ return unless max_llm_calls_per_minute && max_llm_calls_per_minute > 0
543
+
544
+ if llm_call_count_in_last_minute >= max_llm_calls_per_minute
545
+ seconds = time_until_llm_call_count_in_last_minute_changes
546
+ if verbose?
547
+ (io_out || $stdout).puts "Sleeping for #{seconds} seconds to avoid LLM calls per minute limit"
548
+ end
549
+
550
+ sleep seconds
551
+ end
552
+ end
475
553
  end
476
554
  end
477
555
  end
@@ -26,13 +26,20 @@ module Foobara
26
26
  end
27
27
 
28
28
  if command_class.inputs_type
29
- klass.result command_class.inputs_type
29
+ transformer = CommandConnectors::Transformers::EntityToPrimaryKeyInputsTransformer.new(
30
+ to: command_class.inputs_type
31
+ )
32
+ klass.result transformer.from_type
30
33
  end
31
34
 
32
35
  klass
33
36
  end
34
37
  end
35
38
  end
39
+
40
+ def association_depth
41
+ Foobara::JsonSchemaGenerator::AssociationDepth::ATOM
42
+ end
36
43
  end
37
44
  end
38
45
  end
@@ -3,45 +3,30 @@ require "foobara/llm_backed_command"
3
3
  module Foobara
4
4
  class Agent < CommandConnector
5
5
  class DetermineNextCommand < Foobara::LlmBackedCommand
6
- extend Concerns::SubclassCacheable
6
+ description "Accepts the current goal, which might already be accomplished, " \
7
+ "and context of the work " \
8
+ "so far and returns the name of " \
9
+ "the next command to run to make progress towards " \
10
+ "accomplishing the goal. If the goal has already been accomplished then choose the " \
11
+ "NotifyUserThatCurrentGoalHasBeenAccomplished command."
7
12
 
8
- class << self
9
- # Allows us to give a more meaningful result type
10
- def for(command_class_names:, agent_id:)
11
- cached_subclass(agent_id) do
12
- command_name = "Foobara::Agent::#{agent_id}::DetermineNextCommand"
13
- klass = Util.make_class_p(command_name, self)
14
-
15
- klass.description "Accepts the current goal, which might already be accomplished, " \
16
- "and context of the work " \
17
- "so far and returns the name of " \
18
- "the next command to run to make progress towards " \
19
- "accomplishing the goal. If the goal has already been accomplished then choose the " \
20
- "NotifyUserThatCurrentGoalHasBeenAccomplished command."
21
-
22
- klass.inputs do
23
- goal :string, :required, "The current goal to accomplish. If the goal has already been accomplished " \
24
- "by the previous command runs then choose " \
25
- "NotifyUserThatCurrentGoalHasBeenAccomplished to stop the loop."
26
- context Context, :required, "Context of progress so far"
27
- llm_model :string,
28
- one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
29
- default: "claude-3-7-sonnet-20250219",
30
- description: "The model to use for the LLM"
31
- end
32
-
33
- klass.result :string,
34
- one_of: command_class_names,
35
- description: "Name of the next command to run to make progress " \
36
- "towards accomplishing the mission"
37
-
38
- klass
39
- end
40
- end
13
+ inputs do
14
+ goal :string, :required, "The current goal to accomplish. If the goal has already been accomplished " \
15
+ "by the previous command runs then choose " \
16
+ "NotifyUserThatCurrentGoalHasBeenAccomplished to stop the loop."
17
+ context Context, :required, "Context of progress so far"
18
+ llm_model :string,
19
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
20
+ default: "claude-3-7-sonnet-20250219",
21
+ description: "The model to use for the LLM"
41
22
  end
42
23
 
43
- description "Accepts a goal and context of the work so far and returns the name of the next command to run to " \
44
- "make progress towards accomplishing the mission."
24
+ result :string,
25
+ description: "Name of the next command to run to make progress " \
26
+ "towards accomplishing the mission"
27
+ def association_depth
28
+ Foobara::JsonSchemaGenerator::AssociationDepth::ATOM
29
+ end
45
30
  end
46
31
  end
47
32
  end
@@ -14,7 +14,6 @@ module Foobara
14
14
  "by the previous command runs then choose " \
15
15
  "NotifyUserThatCurrentGoalHasBeenAccomplished to stop the loop."
16
16
  context Context, :required, "Context of the progress towards the goal so far"
17
- command_class_names [:string], :required
18
17
  llm_model :string,
19
18
  one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
20
19
  default: "claude-3-7-sonnet-20250219",
@@ -25,6 +24,10 @@ module Foobara
25
24
  command_name :string, :required
26
25
  inputs :attributes, :allow_nil
27
26
  end
27
+
28
+ def association_depth
29
+ Foobara::JsonSchemaGenerator::AssociationDepth::ATOM
30
+ end
28
31
  end
29
32
  end
30
33
  end
data/src/foobara/agent.rb CHANGED
@@ -22,7 +22,8 @@ module Foobara
22
22
  :agent_commands_connected,
23
23
  :verbose,
24
24
  :io_out,
25
- :io_err
25
+ :io_err,
26
+ :max_llm_calls_per_minute
26
27
 
27
28
  def initialize(
28
29
  context: nil,
@@ -34,6 +35,7 @@ module Foobara
34
35
  verbose: false,
35
36
  io_out: nil,
36
37
  io_err: nil,
38
+ max_llm_calls_per_minute: nil,
37
39
  **opts
38
40
  )
39
41
  # TODO: shouldn't have to pass command_log here since it has a default, debug that
@@ -45,12 +47,19 @@ module Foobara
45
47
  self.verbose = verbose
46
48
  self.io_out = io_out
47
49
  self.io_err = io_err
48
-
49
- unless opts.key?(:default_serializers)
50
- opts = opts.merge(default_serializers: [
51
- Foobara::CommandConnectors::Serializers::ErrorsSerializer,
52
- Foobara::CommandConnectors::Serializers::AggregateSerializer
53
- ])
50
+ self.max_llm_calls_per_minute = max_llm_calls_per_minute
51
+
52
+ # unless opts.key?(:default_serializers)
53
+ # opts = opts.merge(default_serializers: [
54
+ # Foobara::CommandConnectors::Serializers::ErrorsSerializer,
55
+ # Foobara::CommandConnectors::Serializers::AggregateSerializer
56
+ # ])
57
+ # end
58
+
59
+ unless opts.key?(:default_pre_commit_transformers)
60
+ opts = opts.merge(
61
+ default_pre_commit_transformers: Foobara::CommandConnectors::Transformers::LoadAtomsPreCommitTransformer
62
+ )
54
63
  end
55
64
 
56
65
  super(**opts)
@@ -73,9 +82,9 @@ module Foobara
73
82
  inputs_transformers = Util.array(inputs_transformers)
74
83
  inputs_transformers << CommandConnectors::Transformers::EntityToPrimaryKeyInputsTransformer
75
84
 
76
- unless opts.key?(:aggregate_entities)
77
- opts = opts.merge(aggregate_entities: true)
78
- end
85
+ # unless opts.key?(:aggregate_entities)
86
+ # opts = opts.merge(aggregate_entities: true)
87
+ # end
79
88
 
80
89
  super(*args, **opts.merge(inputs_transformers:))
81
90
  end
@@ -169,6 +178,10 @@ module Foobara
169
178
  inputs[:include_message_to_user_in_result] = include_message_to_user_in_result
170
179
  end
171
180
 
181
+ if max_llm_calls_per_minute && max_llm_calls_per_minute > 0
182
+ inputs[:max_llm_calls_per_minute] = max_llm_calls_per_minute
183
+ end
184
+
172
185
  self.current_accomplish_goal_command = AccomplishGoal.new(inputs)
173
186
 
174
187
  current_accomplish_goal_command.run.tap do |outcome|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: foobara-agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Miles Georgi