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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +109 -0
- data/README.md +29 -0
- data/TODO.md +5 -0
- data/lib/composable_agents/agent.rb +53 -0
- data/lib/composable_agents/ai_agents/agent.rb +105 -0
- data/lib/composable_agents/ai_agents/tools/ask_user_tool.rb +38 -0
- data/lib/composable_agents/ai_agents/tools/create_artifact_tool.rb +40 -0
- data/lib/composable_agents/ai_agents/tools/get_artifact_tool.rb +39 -0
- data/lib/composable_agents/cline/agent.rb +246 -0
- data/lib/composable_agents/instructions.rb +42 -0
- data/lib/composable_agents/mixins/ai_agent_user_interaction.rb +23 -0
- data/lib/composable_agents/mixins/artifact_contract.rb +181 -0
- data/lib/composable_agents/mixins/logger.rb +47 -0
- data/lib/composable_agents/mixins/resumable.rb +214 -0
- data/lib/composable_agents/mixins/user_interaction.rb +42 -0
- data/lib/composable_agents/prompt_driven_agent.rb +241 -0
- data/lib/composable_agents/prompt_rendering_strategy/markdown.rb +108 -0
- data/lib/composable_agents/prompt_rendering_strategy/markdown_heavy.rb +301 -0
- data/lib/composable_agents/ruby_agent.rb +28 -0
- data/lib/composable_agents/utils/markdown.rb +56 -0
- data/lib/composable_agents/version.rb +6 -0
- data/lib/composable_agents.rb +7 -0
- metadata +117 -0
|
@@ -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
|