composable_agents 1.0.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.
@@ -0,0 +1,214 @@
1
+ require 'json'
2
+ require 'fileutils'
3
+
4
+ module ComposableAgents
5
+ module Mixins
6
+ # Mixin adding resumable step capabilities to agents.
7
+ # An agent prepending this mixin can use the following:
8
+ # * A new constructor named parameter run_id that identifies the run that can be resumable.
9
+ # * A step re-entrant method that defines a part of the agent's processing whose input/output is persisted
10
+ # and that can be skipped if it was previously executed.
11
+ # * An agent_step method that calls a sub-agent with the artifacts and also tracks the state of this agent.
12
+ # * Any agent that implements the methods export_state and import_state will benefit from its state's serialization automatically.
13
+ # * An instance variable @artifacts that stores artifacts (initialized with input ones) that are JSON serialized by steps.
14
+ # Artifacts used with this mixin, and states returned by used agents should be JSON-serializable.
15
+ # This mixin uses the following methods from the agent:
16
+ # - `#export_state -> Object` Optional method returning the current JSON-serializable state of the agent.
17
+ # - `#import_state(state)` Optional method that sets the agent state from a JSON-serializable object.
18
+ module Resumable
19
+ # @!group Public API
20
+
21
+ # Constructor
22
+ #
23
+ # @param run_id [String, nil] ID identifying this run to reuse previously executed steps, or nil if there is no resumability needed
24
+ def initialize(*args, run_id: nil, **kwargs)
25
+ super(*args, **kwargs)
26
+ @run_id = run_id
27
+ end
28
+
29
+ # @!group Internal
30
+
31
+ # Execute the agent to generate some output artifacts based on some input artifacts.
32
+ #
33
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content, per artifact name
34
+ # @return [Hash{Symbol => Object}] The output artifacts returned by the Proc
35
+ def run(**input_artifacts)
36
+ # The artifacts store, JSON serializable
37
+ @artifacts = input_artifacts.dup
38
+ # List of the levels' next step index, following the hierarchy of recusive step calls.
39
+ # This is only used if there is a persistent run ID.
40
+ # For example here are the values of this variable if we have this code:
41
+ # # @steps_idx == [0]
42
+ # step(:a) do
43
+ # # @steps_idx == [0, 0]
44
+ # step(:a1) do
45
+ # # @steps_idx == [0, 0, 0]
46
+ # end
47
+ # # @steps_idx == [0, 1]
48
+ # step(:a2) do
49
+ # # @steps_idx == [0, 1, 0]
50
+ # step(:a21) do
51
+ # # @steps_idx == [0, 1, 0, 0]
52
+ # end
53
+ # # @steps_idx == [0, 1, 1]
54
+ # step(:a22) do
55
+ # # @steps_idx == [0, 1, 1, 0]
56
+ # end
57
+ # # @steps_idx == [0, 1, 2]
58
+ # end
59
+ # # @steps_idx == [0, 2]
60
+ # end
61
+ # # @steps_idx == [1]
62
+ # step(:b) do
63
+ # # @steps_idx == [1, 0]
64
+ # end
65
+ @steps_idx = [0] unless @run_id.nil?
66
+ super
67
+ end
68
+
69
+ private
70
+
71
+ # Define a step that can be serialized and resumed.
72
+ # This will store the state of this step in the file system.
73
+ # If this step was already executed, skip it and update its artifacts from the file system store.
74
+ #
75
+ # @param step_name [Symbol] Step name.
76
+ # @param kwargs [Hash{Symbol => Object}] Additional input artifacts to merge before the step executes.
77
+ # @yield The code called for this step
78
+ def step(step_name = :step, **kwargs, &)
79
+ internal_step(step_name:, agent: nil, extra_input_artifacts: kwargs, &)
80
+ end
81
+
82
+ # Define a step that will just run an agent.
83
+ # This will use the artifacts store for input and output artifacts.
84
+ # Handle the context of the agent if needed.
85
+ #
86
+ # @param agent [Agent] The agent to run.
87
+ # @param kwargs [Hash{Symbol => Object}] Additional input artifacts to merge before the step executes.
88
+ def step_agent(agent, **kwargs)
89
+ internal_step(step_name: :"agent_run_#{(agent.name || 'unnamed').gsub(/[^\w]/, '_')}", agent:, extra_input_artifacts: kwargs) do
90
+ @artifacts.merge!(agent.run(**@artifacts))
91
+ end
92
+ end
93
+
94
+ # Below methods are not supposed to be used directly by the mixin user.
95
+ # They are only internals.
96
+
97
+ # Define a step that can be serialized and resumed.
98
+ # This will store the state of this step in the file system.
99
+ # If this step was already executed, skip it and update its artifacts from the file system store.
100
+ # Handle the state of an optional agent in the case this step is executed for an agent.
101
+ # This method should not be used directly.
102
+ #
103
+ # @param step_name [Symbol] Step name.
104
+ # @param agent [Agent, nil] Agent that is used in this step, or nil if none.
105
+ # @param extra_input_artifacts [Hash{Symbol => Object}] Additional input artifacts to merge before the step executes.
106
+ # @yield The code called for this step
107
+ def internal_step(step_name:, agent:, extra_input_artifacts: {})
108
+ @artifacts.merge!(extra_input_artifacts)
109
+ if @run_id.nil?
110
+ yield
111
+ else
112
+ # Compute the current step state
113
+ step_state = current_step_state(agent:)
114
+ # Read the persisted step state if any
115
+ step_full_name = "#{@steps_idx.join('-')}-#{step_name}"
116
+ saved_input_state, saved_output_state = saved_step_states(step_full_name)
117
+ # If the input exists, it means the step was already executed.
118
+ # If it is the same state as the current one, skip the step and set the current state to the stored output step state.
119
+ if step_state == saved_input_state
120
+ set_current_step_state(saved_output_state, agent:)
121
+ log_debug "[Step #{step_full_name}] - Already executed - Got #{@artifacts.size} artifacts from persistence: #{@artifacts.keys.join(', ')}"
122
+ else
123
+ # Clone state before yielding because it will certainly be modified
124
+ input_step_state = clone_step_state(step_state)
125
+ @steps_idx << 0
126
+ yield
127
+ @steps_idx.pop
128
+ store_step_states(step_full_name, input: input_step_state, output: current_step_state(agent:))
129
+ log_debug "[Step #{step_full_name}] - Executed - Stored #{@artifacts.size} artifacts in persistence: #{@artifacts.keys.join(', ')}"
130
+ end
131
+ @steps_idx[-1] += 1
132
+ end
133
+ end
134
+
135
+ # Get the current step state.
136
+ #
137
+ # @param agent [Agent, nil] Agent that is used in this step, or nil if none.
138
+ # @return [Hash{Symbol => Object}] The current step state
139
+ def current_step_state(agent:)
140
+ step_state = {
141
+ artifacts: @artifacts
142
+ }
143
+ step_state[:agent_state] = agent.export_state if !agent.nil? && agent.respond_to?(:export_state)
144
+ step_state
145
+ end
146
+
147
+ # Get saved step states JSON file for a given full step name.
148
+ #
149
+ # @param step_full_name [String] Step full name
150
+ # @return [String] Corresponding JSON file that stores the step states
151
+ def saved_step_states_json(step_full_name)
152
+ "#{@composable_agents_dir}/runs/#{@run_id}/#{step_full_name.gsub(/[^\w.]/, '_')}.json"
153
+ end
154
+
155
+ # Get saved step states from a given full step name.
156
+ # This will read the step states from the persistence layer.
157
+ #
158
+ # @param step_full_name [String] Step full name
159
+ # @return [Array<Hash{Symbol => Object}, nil>] The saved step states:
160
+ # 0. [Hash{Symbol => Object}, nil] The input step state or nil if none
161
+ # 1. [Hash{Symbol => Object}, nil] The output step state or nil if none
162
+ def saved_step_states(step_full_name)
163
+ step_json_file = saved_step_states_json(step_full_name)
164
+ step_info = File.exist?(step_json_file) ? JSON.parse(File.read(step_json_file)).transform_keys(&:to_sym) : {}
165
+ step_info[:input]&.transform_keys!(&:to_sym)
166
+ step_info.dig(:input, :artifacts)&.transform_keys!(&:to_sym)
167
+ step_info[:output]&.transform_keys!(&:to_sym)
168
+ step_info.dig(:output, :artifacts)&.transform_keys!(&:to_sym)
169
+ [step_info[:input], step_info[:output]]
170
+ end
171
+
172
+ # Store step states for a given full step name in the persistence layer.
173
+ #
174
+ # @param step_full_name [String] Step full name
175
+ # @param input [Hash{Symbol => Object}] The input step state
176
+ # @param output [Hash{Symbol => Object}] The output step state
177
+ def store_step_states(step_full_name, input:, output:)
178
+ step_json_file = saved_step_states_json(step_full_name)
179
+ FileUtils.mkdir_p(File.dirname(step_json_file))
180
+ File.write(
181
+ step_json_file,
182
+ JSON.dump(
183
+ {
184
+ input:,
185
+ output:
186
+ }
187
+ )
188
+ )
189
+ end
190
+
191
+ # Set the current state to a given step state
192
+ #
193
+ # @param step_state [Hash{Symbol => Object}] The step state to use
194
+ # @param agent [Agent, nil] Agent that is used in this step, or nil if none.
195
+ def set_current_step_state(step_state, agent:)
196
+ @artifacts = step_state[:artifacts]
197
+ agent.import_state(step_state[:agent_state]) if !agent.nil? && agent.respond_to?(:import_state)
198
+ end
199
+
200
+ # Clone a step state.
201
+ #
202
+ # @param step_state [Hash{Symbol => Object}] Step state to be cloned
203
+ # @return [Hash{Symbol => Object}] Cloned step state
204
+ def clone_step_state(step_state)
205
+ cloned_step_state = {
206
+ artifacts: step_state[:artifacts].dup
207
+ }
208
+ # Perform a deep clone using JSON
209
+ cloned_step_state[:agent_state] = JSON.parse(JSON.dump(step_state[:agent_state])) if step_state.key?(:agent_state)
210
+ cloned_step_state
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,42 @@
1
+ module ComposableAgents
2
+ module Mixins
3
+ # Mixin that adds user interaction capabilities to PromptDrivenAgent agents.
4
+ # This defines the following methods:
5
+ # - `#ask(question) -> answer` Calls the `#answer_to` method to get the answer to a question and logs the conversation.
6
+ # An agent using this Mixin should define the private method `#answer_to`` to handle the question.
7
+ # Default handling is asking the question on the terminal.
8
+ module UserInteraction
9
+ # @!group Public API
10
+
11
+ # Answer an agent's question
12
+ #
13
+ # @param question [String] The agent's question
14
+ # @return [String] The answer that should be sent back to the agent
15
+ def ask(question)
16
+ track_message(message: question, author: "Agent #{full_name}", question: true)
17
+ answer = answer_to(question)
18
+ track_message(message: answer, author: 'User')
19
+ answer
20
+ end
21
+
22
+ private
23
+
24
+ # Answer an agent's question
25
+ #
26
+ # @param question [String] The agent's question
27
+ # @return [String] The answer that should be sent back to the agent
28
+ def answer_to(question)
29
+ if defined?(super)
30
+ super
31
+ else
32
+ # Provide a default way from terminal
33
+ puts
34
+ puts "Agent is asking a question:\n#{question}"
35
+ puts
36
+ puts 'Write answer and hit Enter...'
37
+ $stdin.gets.strip
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,241 @@
1
+ require 'time'
2
+
3
+ module ComposableAgents
4
+ # Agent implementation that uses a prompt rendering strategy to render prompts for a prompting engine.
5
+ # The following prompts are considered:
6
+ # - A system prompt, that defines the Agent's behaviour, or persona.
7
+ # - User prompts, that are used as user inputs guiding the agent.
8
+ # - Retry prompts, used to tell the Agent that the task at hand is still incomplete and needs more work.
9
+ # For example when an artifact has not been created, and the agent needs to try it again.
10
+ #
11
+ # Prompt rendering strategies are useful because different prompt-driven agents would benefit from different
12
+ # prompt formats or structures (JSON, Markdown, explicit ordered lists, in-lining artifacts' contents without tools...).
13
+ # This agent automatically records non-rendered conversation (prompts + outputs) in a `conversation` store, part of its state.
14
+ class PromptDrivenAgent < Agent
15
+ # @!group Public API
16
+
17
+ # @return [String, nil] Agent's role, or nil for the agent's default
18
+ attr_accessor :role
19
+
20
+ # @return [String, nil] Agent's objective, or nil for the agent's default
21
+ attr_accessor :objective
22
+
23
+ # @return [Object, nil] Agent's original instructions, or nil if no system instructions are needed (see Instructions#initialize)
24
+ attr_accessor :system_instructions
25
+
26
+ # @return [String, nil] Constraints to be respected, or nil for the agent's default
27
+ attr_accessor :constraints
28
+
29
+ # @return [Array<Hash{Symbol => Object}>] The conversation (user prompts, responses) that happened with this agent.
30
+ # Each item of a conversation has the following properties:
31
+ # - author [String] Author of the message
32
+ # - at [String] UTC timestamp of the message at format YYYY-mm-dd HH:MM:SS
33
+ # - message [String] The message itself
34
+ # - question [Boolean] Is this message a question expecting a reply? Defaults to false.
35
+ attr_reader :conversation
36
+
37
+ # Initialize a new PromptDrivenAgent with the information needed for prompts and the selected prompt rendering strategy.
38
+ # If no name is provided, it will default to 'Executor'.
39
+ #
40
+ # @param role [String, nil] Agent's role, or nil for the agent's default
41
+ # @param objective [String, nil] Agent's objective, or nil for the agent's default
42
+ # @param system_instructions [Object, nil] Original instructions for the agent, or nil if no system instructions are needed.
43
+ # The kind of instructions that can be given are defined by the Instructions's constructor (see Instructions#initialize).
44
+ # @param constraints [String, nil] Constraints to be respected, or nil for the agent's default
45
+ # @param strategy [Module] The prompt rendering strategy
46
+ def initialize(
47
+ *args,
48
+ role: nil,
49
+ objective: nil,
50
+ system_instructions: nil,
51
+ constraints: nil,
52
+ strategy: PromptRenderingStrategy::Markdown,
53
+ **kwargs
54
+ )
55
+ super(*args, **kwargs)
56
+ singleton_class.include strategy
57
+ @role = role
58
+ @objective = objective
59
+ @system_instructions = system_instructions
60
+ @constraints = constraints
61
+ @conversation = []
62
+ end
63
+
64
+ # Execute the agent to generate some output artifacts based on some input artifacts.
65
+ #
66
+ # @param user_instructions [Object, nil] Instructions for the user prompt, that will be rendered.
67
+ # The kind of instructions that can be given are defined by the Instructions's constructor (see Instructions#initialize).
68
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content, per artifact name
69
+ # @return [Hash{Symbol => Object}] The output artifacts
70
+ def run(user_instructions: nil, **input_artifacts)
71
+ @input_artifacts = input_artifacts
72
+ @output_artifacts = {}
73
+ @output_artifacts_errors = {}
74
+ @system_prompt = render_system_prompt(render_instructions(@system_instructions))
75
+ log_debug "System prompt: #{@system_prompt}"
76
+ converse(user_instructions, input_artifacts: @input_artifacts, author: 'User')
77
+ if respond_to?(:normalized_output_artifacts_contracts, true)
78
+ # We know which output artifacts we are expecting.
79
+ # Therefore check if some are missing and prompt again if that's the case.
80
+ # TODO: Implement a max number of retries and throw an exception if it exceeds.
81
+ loop do
82
+ missing_artifacts = normalized_output_artifacts_contracts
83
+ .reject { |artifact_name, _artifact_description| @output_artifacts.key?(artifact_name) }
84
+ .to_h do |artifact_name, artifact_description|
85
+ [
86
+ artifact_name,
87
+ artifact_description.merge(@output_artifacts_errors[artifact_name] ? { error: @output_artifacts_errors[artifact_name] } : {})
88
+ ]
89
+ end
90
+ break if missing_artifacts.empty?
91
+
92
+ converse(missing_output_user_instructions(missing_artifacts))
93
+ end
94
+ end
95
+ @output_artifacts
96
+ end
97
+
98
+ # @!group Internal
99
+
100
+ # Define input artifacts contracts
101
+ #
102
+ # @return [Hash{Symbol => Object}] Set of input artifacts description, per artifact name
103
+ def input_artifacts_contracts
104
+ {
105
+ user_instructions: {
106
+ description: 'User instructions',
107
+ optional: true
108
+ }
109
+ }
110
+ end
111
+
112
+ # Define output artifacts contracts
113
+ #
114
+ # @return [Hash{Symbol => Object}>] Set of output artifacts description, per artifact name
115
+ def output_artifacts_contracts
116
+ {}
117
+ end
118
+
119
+ # Export the agent state for persistence
120
+ #
121
+ # @return [Object] Serialized state that can be marshalled to JSON
122
+ def export_state
123
+ deep_transform_keys(
124
+ {
125
+ conversation: @conversation.map do |message|
126
+ message.merge(at: message[:at].strftime('%F %T'))
127
+ end
128
+ },
129
+ &:to_s
130
+ )
131
+ end
132
+
133
+ # Import the agent state from persistence
134
+ #
135
+ # @param state [Object] Serialized state
136
+ def import_state(state)
137
+ @conversation = deep_transform_keys(state, &:to_sym)[:conversation].map do |message|
138
+ message.merge(at: Time.parse("#{message.delete(:at)} UTC"))
139
+ end
140
+ end
141
+
142
+ # Save an output artifact.
143
+ # This method can be used at anytime while prompting, when the agent is able to produce an output artifact.
144
+ #
145
+ # @param artifact_name [Symbol] Output artifact name
146
+ # @param content [Object] Output artifact content
147
+ def save_output_artifact(artifact_name, content)
148
+ @output_artifacts[artifact_name] = content
149
+ @output_artifacts_errors.delete(artifact_name)
150
+ log_debug "[Artifact] - Received output artifact #{artifact_name}"
151
+ end
152
+
153
+ # Report an error on an output artifact.
154
+ # This method can be used at anytime while prompting, when the agent is unable to produce an output artifact
155
+ # because of an error that should be communicated back to the agent.
156
+ # Make sure previous versions of this output artifact are removed to not store wrong versions by mistake.
157
+ #
158
+ # @param artifact_name [Symbol] Output artifact name
159
+ # @param error [String] Error associated to this output artifact
160
+ def report_error_for_output_artifact(artifact_name, error)
161
+ @output_artifacts.delete(artifact_name)
162
+ @output_artifacts_errors[artifact_name] = error
163
+ # TODO: Make this as a warning message
164
+ log_debug "[Artifact] - Should have received content for output artifact `#{artifact_name}` " \
165
+ "but the following error occurred: #{error}"
166
+ end
167
+
168
+ private
169
+
170
+ # Render instructions using the prompt rendering strategy.
171
+ # Returns nil if instructions is nil.
172
+ #
173
+ # @param instructions [Object, nil] Instructions to render, or nil if none (see Instructions#initialize).
174
+ # @return [String, nil] The rendered instructions, or nil if none
175
+ def render_instructions(instructions)
176
+ return nil unless instructions
177
+
178
+ render_instructions_list(
179
+ Instructions.new(instructions).map do |instruction_type, instruction|
180
+ send(:"render_instruction_#{instruction_type}", instruction)
181
+ end
182
+ )
183
+ end
184
+
185
+ # Prompt a user prompt and record it with its response in the conversation.
186
+ #
187
+ # @param instructions [Object, nil] The instructions for the user prompt (see Instructions#initialize), or nil if none
188
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content, per artifact name
189
+ # @param author [String] Author of this message. Usually User if it is user input, but can be Orchestrator or anything else
190
+ def converse(instructions, input_artifacts: {}, author: 'Orchestrator')
191
+ rendered_instructions = render_instructions(instructions)
192
+ rendered_user_prompt = render_user_prompt(rendered_instructions, input_artifacts:)
193
+ log_debug "Rendered User prompt: #{rendered_user_prompt}"
194
+ track_message(message: rendered_instructions, author:)
195
+ response = prompt(rendered_user_prompt)
196
+ log_debug "Raw Agent #{full_name} response: #{response}"
197
+ track_message(message: response, author: "Agent #{full_name}")
198
+ end
199
+
200
+ # Process a user prompt.
201
+ #
202
+ # @param user_prompt [String] The rendered user prompt
203
+ # @return [String] The output of the prompt
204
+ def prompt(user_prompt)
205
+ raise NotImplementedError, 'This method should be implemented by a PromptDrivenAgent subclass'
206
+ end
207
+
208
+ # Track a message that is part of the conversation with this agent
209
+ #
210
+ # @param message [String, #to_hash, nil] The message content, as a String or an object that can be hashed, or nil if none.
211
+ # @param author [String] Author of the message.
212
+ # @param question [Boolean] Is this message a question expecting a reply?
213
+ def track_message(message:, author: 'Orchestrator', question: false)
214
+ @conversation << {
215
+ at: Time.now.utc,
216
+ author:,
217
+ message: message.is_a?(String) ? message : message&.to_hash,
218
+ question:
219
+ }
220
+ end
221
+
222
+ # Apply a deep nested transformation on a Hash's keys.
223
+ # Traverse nested arrays and hashes.
224
+ #
225
+ # @param obj [Object] The source object to transform (could be Hash, Array, or any other object).
226
+ # @yield [#call(key) -> Object] Transformation operation
227
+ # @yieldparam key [Object] Source key to be transformed
228
+ # @yieldreturn [Object] Transformed key
229
+ # @return [Object] The transformed object
230
+ def deep_transform_keys(obj, &)
231
+ case obj
232
+ when Hash
233
+ obj.each_with_object({}) { |(key, value), result| result[yield(key)] = deep_transform_keys(value, &) }
234
+ when Array
235
+ obj.map { |value| deep_transform_keys(value, &) }
236
+ else
237
+ obj
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,108 @@
1
+ module ComposableAgents
2
+ # Collection of prompt rendering strategies that can be used with PromptDrivenAgent's agents
3
+ module PromptRenderingStrategy
4
+ # Render prompt as Markdown documents
5
+ module Markdown
6
+ # @!group Internal
7
+
8
+ # Render an instruction of type text
9
+ #
10
+ # @param instruction [String] The instruction to render
11
+ # @return [String] The rendered instruction
12
+ def render_instruction_text(instruction)
13
+ instruction
14
+ end
15
+
16
+ # Render an instruction of type ordered_list
17
+ #
18
+ # @param instruction [Array<String>] The instruction to render
19
+ # @return [String] The rendered instruction
20
+ def render_instruction_ordered_list(instruction)
21
+ instruction.map.with_index { |step, step_idx| "# #{step_idx + 1}. #{step}" }.join("\n\n")
22
+ end
23
+
24
+ # Render a list of rendered instructions
25
+ #
26
+ # @param instructions [Array<String>] The instructions list to render
27
+ # @return [String] The rendered instructions list
28
+ def render_instructions_list(instructions)
29
+ instructions.map { |instruction| Utils::Markdown.align_markdown_headers(instruction, level: 1).strip }.join("\n\n")
30
+ end
31
+
32
+ # Render the system prompt.
33
+ # The following instance variables are accessible to render the prompt:
34
+ # - `@input_artifacts`
35
+ # - `@role`
36
+ # - `@objective`
37
+ # - `@constraints`
38
+ #
39
+ # @param rendered_instructions [String, nil] The rendered system instructions, or nil if none
40
+ # @return [String] The rendered system prompt
41
+ def render_system_prompt(rendered_instructions)
42
+ sections = []
43
+ sections << <<~EO_SECTION if @role && !@role.empty?
44
+ # Role
45
+
46
+ #{Utils::Markdown.align_markdown_headers(@role, level: 2).strip}
47
+ EO_SECTION
48
+ sections << <<~EO_SECTION if @objective && !@objective.empty?
49
+ # Objective
50
+
51
+ #{Utils::Markdown.align_markdown_headers(@objective, level: 2).strip}
52
+ EO_SECTION
53
+ sections << <<~EO_SECTION if rendered_instructions && !rendered_instructions.empty?
54
+ # Instructions
55
+
56
+ #{Utils::Markdown.align_markdown_headers(rendered_instructions, level: 2).strip}
57
+ EO_SECTION
58
+ sections << <<~EO_SECTION if @constraints && !@constraints.empty?
59
+ # Constraints
60
+
61
+ #{Utils::Markdown.align_markdown_headers(@constraints, level: 2).strip}
62
+ EO_SECTION
63
+ sections.map(&:strip).join("\n\n")
64
+ end
65
+
66
+ # Render the user prompt
67
+ # The following instance variables are accessible to render the prompt:
68
+ # - `@role`
69
+ # - `@objective`
70
+ # - `@constraints`
71
+ #
72
+ # @param rendered_instructions [String, nil] The rendered instructions, or nil if none
73
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content for which we render this prompt, per artifact name
74
+ # @return [String] The rendered user prompt
75
+ def render_user_prompt(rendered_instructions, input_artifacts:)
76
+ rendered_instructions || ''
77
+ end
78
+
79
+ # Get the artifact reference name communicated to the assistant
80
+ #
81
+ # @param artifact_name [Symbol] The artifact name
82
+ # @return [String] The artifact reference name used for the assistant
83
+ def artifact_ref(artifact_name)
84
+ artifact_name.to_s
85
+ end
86
+
87
+ # Get user instructions for missing output artifacts
88
+ #
89
+ # @param missing_output_artifacts [Hash{Symbol => Object}] The missing output artifacts information, per artifact name
90
+ # Information can contain the following attributes:
91
+ # - description [String] The artifact's description.
92
+ # - error [String, nil] An error message related to this missing artifact.
93
+ # @return [Object] The user instructions (see Instructions#initialize)
94
+ def missing_output_user_instructions(missing_output_artifacts)
95
+ <<~EO_PROMPT
96
+ Some artifacts are missing:
97
+ #{
98
+ (
99
+ missing_output_artifacts.map do |artifact_name, missing_info|
100
+ "- You must create an artifact named `#{artifact_name}`: #{missing_info[:description]}"
101
+ end
102
+ ).join("\n")
103
+ }
104
+ EO_PROMPT
105
+ end
106
+ end
107
+ end
108
+ end