foobara-agent 0.0.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.
@@ -0,0 +1,240 @@
1
+ require_relative "list_commands"
2
+
3
+ module Foobara
4
+ # TODO: should agent maybe be a command connector? It feels a bit more like a command connector.
5
+ class Agent
6
+ class AccomplishGoal < Foobara::Command
7
+ possible_error :gave_up, context: { reason: :string }, message: "Gave up."
8
+
9
+ inputs do
10
+ agent_name :string, "Name of the agent"
11
+ goal :string, :required, "What do you want the agent to attempt to accomplish?"
12
+ # TODO: we should be able to specify a subclass as a type
13
+ command_classes [:duck], "Commands that can be ran to accomplish the goal"
14
+ final_result_type :duck, "Specifies how the result of the goal is to be structured"
15
+ existing_command_connector :duck, "A connector containing already-connected commands for the agent to use"
16
+ current_context :duck, "The current context of the agent"
17
+ llm_model :string,
18
+ :allow_nil,
19
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
20
+ default: "claude-3-7-sonnet-20250219",
21
+ description: "The model to use for the LLM"
22
+ end
23
+
24
+ result do
25
+ message_to_user :string, :required, "Message to the user about successfully accomplishing the goal"
26
+ result_data :duck, "Optional result data to return to the user if final_result_type was given"
27
+ end
28
+
29
+ depends_on ListCommands
30
+
31
+ def execute
32
+ build_initial_context_if_necessary
33
+
34
+ if command_connector_passed_in?
35
+ set_accomplished_goal_command
36
+ else
37
+ build_command_connector
38
+ connect_user_provided_commands
39
+ connect_agent_commands
40
+ end
41
+
42
+ unless command_connector.agent_commands_connected?
43
+ connect_agent_commands
44
+ end
45
+
46
+ until mission_accomplished or given_up or timed_out
47
+ determine_next_command_name
48
+
49
+ if command_described?
50
+ fetch_next_command_class
51
+ determine_next_command_inputs
52
+ else
53
+ choose_describe_command_instead
54
+ fetch_next_command_class
55
+ end
56
+
57
+ run_next_command
58
+ log_command_outcome
59
+ end
60
+
61
+ if given_up
62
+ add_given_up_error
63
+ end
64
+
65
+ build_result
66
+ end
67
+
68
+ def validate
69
+ validate_either_command_classes_or_connector_given
70
+ end
71
+
72
+ def validate_either_command_classes_or_connector_given
73
+ # TODO: implement this!
74
+ end
75
+
76
+ attr_accessor :context, :next_command_name, :next_command_inputs, :mission_accomplished, :given_up,
77
+ :next_command_class, :next_command, :command_outcome, :timed_out,
78
+ :final_result, :final_message, :command_response, :delayed_command_name
79
+ attr_writer :command_connector
80
+
81
+ def agent_name
82
+ @agent_name ||= inputs[:agent_name] || "Anon#{SecureRandom.hex(2)}"
83
+ end
84
+
85
+ def build_initial_context_if_necessary
86
+ # TODO: shouldn't have to pass command_log here since it has a default, debug that
87
+ self.context = current_context || Context.new(command_log: [])
88
+ end
89
+
90
+ def command_connector_passed_in?
91
+ existing_command_connector
92
+ end
93
+
94
+ def command_connector
95
+ @command_connector ||= existing_command_connector
96
+ end
97
+
98
+ def build_command_connector
99
+ self.command_connector ||= Connector.new(
100
+ accomplish_goal_command: self,
101
+ default_serializers: [
102
+ Foobara::CommandConnectors::Serializers::ErrorsSerializer,
103
+ Foobara::CommandConnectors::Serializers::AtomicSerializer,
104
+ Foobara::CommandConnectors::Serializers::JsonSerializer
105
+ ],
106
+ llm_model:
107
+ )
108
+ end
109
+
110
+ def set_accomplished_goal_command
111
+ command_connector.accomplish_goal_command = self
112
+ end
113
+
114
+ def connect_agent_commands
115
+ command_connector.connect_agent_commands(final_result_type:, agent_name:)
116
+ end
117
+
118
+ def connect_user_provided_commands
119
+ command_classes.each do |command_class|
120
+ command_connector.connect(command_class)
121
+ end
122
+ end
123
+
124
+ def determine_next_command_name
125
+ self.next_command_name = if context.command_log.empty?
126
+ ListCommands.full_command_name
127
+ elsif delayed_command_name
128
+ name = delayed_command_name
129
+ self.delayed_command_name = nil
130
+ name
131
+ else
132
+ command_class = DetermineNextCommand.for(
133
+ command_class_names: all_command_classes, agent_id: agent_name
134
+ )
135
+
136
+ inputs = { goal:, context: }
137
+ if llm_model
138
+ inputs[:llm_model] = llm_model
139
+ end
140
+
141
+ command_class.run!(inputs)
142
+ end
143
+ end
144
+
145
+ def choose_describe_command_instead
146
+ self.delayed_command_name = next_command_name
147
+ self.next_command_inputs = { command_name: next_command_name }
148
+ self.next_command_name = DescribeCommand.full_command_name
149
+ end
150
+
151
+ def all_command_classes
152
+ @all_command_classes ||= run_subcommand!(ListCommands, command_connector:).values.flatten
153
+ end
154
+
155
+ def fetch_next_command_class
156
+ self.next_command_class = command_connector.transformed_command_from_name(next_command_name)
157
+ end
158
+
159
+ def determine_next_command_inputs
160
+ type = next_command_class.inputs_type
161
+
162
+ self.next_command_inputs = if type && !empty_attributes?(type)
163
+ command_class = DetermineInputsForNextCommand.for(
164
+ command_class: next_command_class, agent_id: agent_name
165
+ )
166
+
167
+ inputs = { goal:, context: }
168
+ if llm_model
169
+ inputs[:llm_model] = llm_model
170
+ end
171
+
172
+ command_class.run!(inputs)
173
+ end
174
+ end
175
+
176
+ def run_next_command
177
+ self.command_response = command_connector.run(
178
+ full_command_name: next_command_name,
179
+ inputs: next_command_inputs,
180
+ action: "run"
181
+ )
182
+
183
+ self.command_outcome = command_response.outcome
184
+ end
185
+
186
+ def log_command_outcome
187
+ outcome_hash = { success: command_outcome.success? }
188
+
189
+ if command_outcome.success?
190
+ outcome_hash[:result] = command_response.body
191
+ else
192
+ # :nocov:
193
+ outcome_hash[:errors_hash] = command_response.body
194
+ # :nocov:
195
+ end
196
+
197
+ context.command_log << CommandLogEntry.new(
198
+ command_name: next_command_name,
199
+ inputs: next_command_inputs,
200
+ outcome: outcome_hash
201
+ )
202
+ end
203
+
204
+ # TODO: these are awkwardly called from outside. Come up with a better solution.
205
+ def mission_accomplished!(final_result, message)
206
+ self.mission_accomplished = true
207
+ self.final_result = final_result
208
+ self.final_message = message
209
+ end
210
+
211
+ def give_up!(message)
212
+ self.given_up = true
213
+ self.final_message = message
214
+ end
215
+
216
+ def add_given_up_error
217
+ add_runtime_error(:gave_up, reason: final_message)
218
+ end
219
+
220
+ def build_result
221
+ {
222
+ message_to_user: final_message,
223
+ result_data: final_result
224
+ }
225
+ end
226
+
227
+ def command_described?
228
+ described_commands.include?(next_command_name)
229
+ end
230
+
231
+ def described_commands
232
+ @described_commands ||= Set.new
233
+ end
234
+
235
+ def empty_attributes?(type)
236
+ type.extends_type?(BuiltinTypes[:attributes]) && type.element_types.empty?
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,59 @@
1
+ require "foobara/command_connectors"
2
+
3
+ module Foobara
4
+ class Agent
5
+ class Connector < Foobara::CommandConnector
6
+ attr_accessor :accomplish_goal_command, :agent_commands_connected, :llm_model
7
+
8
+ def initialize(*, accomplish_goal_command:, llm_model: nil, **)
9
+ self.accomplish_goal_command = accomplish_goal_command
10
+ self.llm_model = llm_model
11
+
12
+ super(*, **)
13
+ end
14
+
15
+ def mark_mission_accomplished(final_result, message_to_user)
16
+ # TODO: this is a pretty awkward way to communicate between commands hmmm...
17
+ # maybe see if there's a less hacky way to pull this off.
18
+ accomplish_goal_command.mission_accomplished!(final_result, message_to_user)
19
+ end
20
+
21
+ def give_up(reason)
22
+ accomplish_goal_command.give_up!(reason)
23
+ end
24
+
25
+ def agent_commands_connected?
26
+ agent_commands_connected
27
+ end
28
+
29
+ def connect_agent_commands(final_result_type: nil, agent_name: nil)
30
+ command_classes = [
31
+ DescribeCommand,
32
+ DescribeType,
33
+ GiveUp,
34
+ ListCommands,
35
+ ListTypes
36
+ ]
37
+
38
+ command_classes << if final_result_type
39
+ EndSessionBecauseGoalHasBeenAccomplished.for(
40
+ result_type: final_result_type,
41
+ agent_id: agent_name
42
+ )
43
+ else
44
+ EndSessionBecauseGoalHasBeenAccomplished
45
+ end
46
+
47
+ command_classes.each do |command_class|
48
+ connect(command_class, inputs: set_command_connector_transformer)
49
+ end
50
+
51
+ self.agent_commands_connected = true
52
+ end
53
+
54
+ def set_command_connector_transformer
55
+ @set_command_connector_transformer ||= SetCommandConnectorInputsTransformer.for(self)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,28 @@
1
+ module Foobara
2
+ class Agent
3
+ class SetCommandConnectorInputsTransformer < TypeDeclarations::TypedTransformer
4
+ class << self
5
+ attr_accessor :command_connector
6
+
7
+ def for(command_connector)
8
+ Class.new(self).tap do |subclass|
9
+ subclass.command_connector = command_connector
10
+ end
11
+ end
12
+ end
13
+
14
+ def command_connector
15
+ self.class.command_connector
16
+ end
17
+
18
+ def from_type_declaration
19
+ to_declaration = to_type.declaration_data
20
+ TypeDeclarations::Attributes.reject(to_declaration, :command_connector)
21
+ end
22
+
23
+ def transform(inputs)
24
+ inputs.merge(command_connector:)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,59 @@
1
+ module Foobara
2
+ class Agent
3
+ class DescribeCommand < Foobara::Command
4
+ inputs do
5
+ command_connector :duck, :required, "Connector to find relevant command in"
6
+ command_name :string, :required, "Name of the command to describe"
7
+ end
8
+
9
+ result :duck, description: "Information about the command"
10
+
11
+ def execute
12
+ find_command_class
13
+
14
+ set_command_name
15
+ set_description
16
+ set_inputs_type
17
+ set_result_type
18
+
19
+ mark_command_as_described
20
+
21
+ command_description
22
+ end
23
+
24
+ attr_accessor :command_class
25
+
26
+ def find_command_class
27
+ self.command_class = command_connector.transformed_command_from_name(command_name)
28
+ end
29
+
30
+ def command_description
31
+ @command_description ||= {}
32
+ end
33
+
34
+ def set_command_name
35
+ command_description[:full_command_name] = command_class.full_command_name
36
+ end
37
+
38
+ def set_description
39
+ command_description[:description] = command_class.description
40
+ end
41
+
42
+ def set_inputs_type
43
+ if command_class.inputs_type
44
+ command_description[:inputs_type] = JsonSchemaGenerator.to_json_schema(command_class.inputs_type)
45
+ end
46
+ end
47
+
48
+ def set_result_type
49
+ if command_class.result_type
50
+ command_description[:result_type] = JsonSchemaGenerator.to_json_schema(command_class.result_type)
51
+ end
52
+ end
53
+
54
+ def mark_command_as_described
55
+ command_connector.accomplish_goal_command.described_commands << command_class.full_command_name
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,39 @@
1
+ module Foobara
2
+ class Agent
3
+ class DescribeType < Foobara::Command
4
+ inputs do
5
+ command_connector :duck, :required, "Connector to find relevant type in"
6
+ type_name :string, :required, "Name of the type to describe"
7
+ end
8
+
9
+ result :duck, description: "Information about the type"
10
+
11
+ def execute
12
+ find_type
13
+
14
+ set_type_name
15
+ set_json_schema
16
+
17
+ type_description
18
+ end
19
+
20
+ attr_accessor :type
21
+
22
+ def find_type
23
+ self.type = command_connector.command_registry.foobara_lookup_type(type_name)
24
+ end
25
+
26
+ def type_description
27
+ @type_description ||= {}
28
+ end
29
+
30
+ def set_type_name
31
+ type_description[:full_type_name] = type.scoped_full_name
32
+ end
33
+
34
+ def set_json_schema
35
+ type_description[:json_schema] = JsonSchemaGenerator.to_json_schema(type)
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,75 @@
1
+ require "foobara/llm_backed_command"
2
+
3
+ module Foobara
4
+ class Agent
5
+ class DetermineInputsForNextCommand < Foobara::LlmBackedCommand
6
+ class << self
7
+ attr_accessor :command_class
8
+
9
+ def command_cache
10
+ @command_cache ||= {}
11
+ end
12
+
13
+ def clear_cache
14
+ @command_cache = nil
15
+ end
16
+
17
+ def cached_command(agent_id, full_command_name)
18
+ key = [agent_id, full_command_name]
19
+
20
+ if command_cache.key?(key)
21
+ command_cache[key]
22
+ else
23
+ command_cache[key] = yield
24
+ end
25
+ end
26
+
27
+ def for(command_class:, agent_id:)
28
+ cached_command(agent_id, command_class.full_command_name) do
29
+ command_short_name = Util.non_full_name(command_class.command_name)
30
+ class_name = "Foobara::Agent::#{agent_id}::DetermineInputsForNext#{command_short_name}Command"
31
+ klass = Util.make_class_p(class_name, self)
32
+
33
+ klass.command_class = command_class
34
+
35
+ klass.description "Accepts a goal and context of the work so far and returns the inputs for " \
36
+ "the next #{command_short_name} command to run to make progress towards " \
37
+ "accomplishing the goal."
38
+
39
+ klass.inputs do
40
+ goal :string, :required, "What do you want the agent to attempt to accomplish?"
41
+ context Context, :required, "Context of the progress towards the goal so far"
42
+ llm_model :string,
43
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
44
+ default: "claude-3-7-sonnet-20250219",
45
+ description: "The model to use for the LLM"
46
+ end
47
+
48
+ if command_class.inputs_type
49
+ klass.result command_class.inputs_type
50
+ end
51
+
52
+ klass
53
+ end
54
+ end
55
+ end
56
+
57
+ description "Accepts a goal and context of the work so far and returns the inputs for the next command to " \
58
+ "run to make progress towards accomplishing the mission."
59
+
60
+ inputs do
61
+ goal :string, :required, "What do you want the agent to attempt to accomplish?"
62
+ context Context, :required, "Context of the current mission so far"
63
+ command_class :duck, :required, "Command to run to accomplish the goal"
64
+ llm_model :string,
65
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
66
+ default: "claude-3-7-sonnet-20250219",
67
+ description: "The model to use for the LLM"
68
+ end
69
+
70
+ result :duck,
71
+ description: "Inputs to pass to the next command to run to make progress " \
72
+ "towards accomplishing the mission."
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,67 @@
1
+ require "foobara/llm_backed_command"
2
+
3
+ module Foobara
4
+ class Agent
5
+ class DetermineNextCommand < Foobara::LlmBackedCommand
6
+ class << self
7
+ attr_accessor :command_class_names
8
+
9
+ def command_cache
10
+ @command_cache ||= {}
11
+ end
12
+
13
+ def clear_cache
14
+ @command_cache = nil
15
+ end
16
+
17
+ def cached_command(agent_id)
18
+ if command_cache.key?(agent_id)
19
+ command_cache[agent_id]
20
+ else
21
+ command_cache[agent_id] = yield
22
+ end
23
+ end
24
+
25
+ def for(command_class_names:, agent_id:)
26
+ cached_command(agent_id) do
27
+ command_name = "Foobara::Agent::#{agent_id}::DetermineNextCommand"
28
+ klass = Util.make_class_p(command_name, self)
29
+
30
+ klass.command_class_names = command_class_names
31
+
32
+ klass.inputs do
33
+ goal :string, :required, "What do you want the agent to attempt to accomplish?"
34
+ context Context, :required, "Context of the current mission so far"
35
+ llm_model :string,
36
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
37
+ default: "claude-3-7-sonnet-20250219",
38
+ description: "The model to use for the LLM"
39
+ end
40
+
41
+ klass.result :string,
42
+ one_of: command_class_names,
43
+ description: "Name of the next command to run to make progress " \
44
+ "towards accomplishing the mission"
45
+
46
+ klass
47
+ end
48
+ end
49
+ end
50
+
51
+ description "Accepts a goal and context of the work so far and returns the name of the next command to run to " \
52
+ "make progress towards accomplishing the mission. Make sure you have called DescribeCommand the" \
53
+ "command first so that you will know how to construct its inputs in the next step."
54
+
55
+ inputs do
56
+ goal :string, :required, "What do you want the agent to attempt to accomplish?"
57
+ context Context, :required, "Context of the current mission so far"
58
+ llm_model :string,
59
+ one_of: Foobara::Ai::AnswerBot::Types::ModelEnum,
60
+ default: "claude-3-7-sonnet-20250219",
61
+ description: "The model to use for the LLM"
62
+ end
63
+
64
+ result :string, description: "Name of the next command to run to make progress towards accomplishing the mission."
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,99 @@
1
+ module Foobara
2
+ class Agent
3
+ class EndSessionBecauseGoalHasBeenAccomplished < Foobara::Command
4
+ class << self
5
+ attr_accessor :command_class
6
+
7
+ def command_cache
8
+ @command_cache ||= {}
9
+ end
10
+
11
+ def clear_cache
12
+ @command_cache = nil
13
+ end
14
+
15
+ def cached_command(agent_id, result_type)
16
+ key = [agent_id, result_type]
17
+
18
+ if command_cache.key?(key)
19
+ # :nocov:
20
+ command_cache[key]
21
+ # :nocov:
22
+ else
23
+ command_cache[key] = yield
24
+ end
25
+ end
26
+
27
+ def for(result_type:, agent_id:)
28
+ cached_command(agent_id, result_type) do
29
+ command_name = "Foobara::Agent::#{agent_id}::EndSessionBecauseGoalHasBeenAccomplished"
30
+ klass = Util.make_class_p(command_name, self)
31
+
32
+ klass.description "Ends the session giving a final result formatted according to the " \
33
+ "result schema if relevant and an optional message to the user."
34
+
35
+ inputs do
36
+ # TODO: Are we still not able to uses classes as foobara types??
37
+ command_connector :duck, :required, "Connector to end"
38
+ message_to_user :string, "Optional message to the user"
39
+ end
40
+
41
+ if result_type
42
+ add_inputs do
43
+ result_data(*result_type)
44
+ end
45
+
46
+ klass.result do
47
+ message_to_user :string
48
+ result_data(*result_type)
49
+ end
50
+
51
+ klass.description "Ends the session giving a final result formatted according to the " \
52
+ "result schema and an optional message to the user."
53
+ else
54
+ # TODO: test this code path
55
+ # :nocov:
56
+ klass.description "Ends the session giving an optional message to the user."
57
+ # :nocov:
58
+ end
59
+
60
+ klass
61
+ end
62
+ end
63
+ end
64
+
65
+ description "Ends the session giving a final result formatted according to the " \
66
+ "result schema if relevant and an optional message to the user."
67
+
68
+ inputs do
69
+ # TODO: Are we still not able to uses classes as foobara types??
70
+ command_connector :duck, :required, "Connector to end"
71
+ message_to_user :string, "Optional message to the user"
72
+ result_data :duck, "The final result of the work if relevant/expected"
73
+ end
74
+
75
+ def execute
76
+ mark_mission_accomplished
77
+
78
+ parsed_result
79
+ end
80
+
81
+ def mark_mission_accomplished
82
+ data = if result_type
83
+ inputs[:result_data]
84
+ end
85
+ command_connector.mark_mission_accomplished(data, message_to_user)
86
+ end
87
+
88
+ def parsed_result
89
+ h = { message_to_user: }
90
+
91
+ if inputs[:result_data] && result_type
92
+ h[:result_data] = result_data
93
+ end
94
+
95
+ h
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,20 @@
1
+ module Foobara
2
+ class Agent
3
+ class GiveUp < Foobara::Command
4
+ inputs do
5
+ command_connector :duck, :required, "Connector to end"
6
+ message_to_user :string, "Optional message to the user explaining why you decided to give up"
7
+ end
8
+
9
+ def execute
10
+ mark_given_up
11
+
12
+ nil
13
+ end
14
+
15
+ def mark_given_up
16
+ command_connector.give_up(message_to_user)
17
+ end
18
+ end
19
+ end
20
+ end