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,246 @@
|
|
|
1
|
+
require 'cline'
|
|
2
|
+
|
|
3
|
+
module ComposableAgents
|
|
4
|
+
# All agents from this module work with the awesome cline-rb Rubygem
|
|
5
|
+
module Cline
|
|
6
|
+
# Missin skill error
|
|
7
|
+
class MissingSkillError < RuntimeError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Agent implementation that uses an ai-agent's AgentRunner.
|
|
11
|
+
class Agent < PromptDrivenAgent
|
|
12
|
+
# @!group Public API
|
|
13
|
+
|
|
14
|
+
prepend Mixins::ArtifactContract
|
|
15
|
+
|
|
16
|
+
# Initialize a new agent that uses the Cline CLI in a dedicated config
|
|
17
|
+
#
|
|
18
|
+
# @param strategy [Module] The prompt rendering strategy
|
|
19
|
+
# @param provider [String] Provider to be used
|
|
20
|
+
# @param model [String] Model to be used
|
|
21
|
+
# @param api_key [String] API key to be used
|
|
22
|
+
# @param configure_provider [#call(provider_settings), nil] Optional block used to configure the provider settings
|
|
23
|
+
# * Param provider_settings [Cline::Providers::ProviderSettings] Settings that can be tuned for this agent
|
|
24
|
+
# @param configure_global [#call(global_settings), nil] Optional block used to configure the global settings
|
|
25
|
+
# * Param global_settings [Cline::GlobalSettings] Settings that can be tuned for this agent
|
|
26
|
+
# @param skills [Array<String>] List of skills to allow for this agent
|
|
27
|
+
# @param cli_options [Hash{Symbol => Object}] Task options to give to Cline CLI (see Cline::Cli.COMMANDS)
|
|
28
|
+
def initialize(
|
|
29
|
+
*args,
|
|
30
|
+
strategy: PromptRenderingStrategy::MarkdownHeavy,
|
|
31
|
+
provider: 'cline',
|
|
32
|
+
model: 'anthropic/claude-sonnet-4.6',
|
|
33
|
+
api_key: ENV.fetch('CLINE_API_KEY', nil),
|
|
34
|
+
configure_provider: nil,
|
|
35
|
+
configure_global: nil,
|
|
36
|
+
skills: [],
|
|
37
|
+
cli_options: {},
|
|
38
|
+
**kwargs
|
|
39
|
+
)
|
|
40
|
+
super(*args, strategy:, **kwargs)
|
|
41
|
+
@provider = provider
|
|
42
|
+
@model = model
|
|
43
|
+
@api_key = api_key ? ::Cline::SecretString.new(api_key.dup) : nil
|
|
44
|
+
@configure_provider = configure_provider
|
|
45
|
+
@configure_global = configure_global
|
|
46
|
+
@skills = skills
|
|
47
|
+
@cli_options = cli_options
|
|
48
|
+
@context = []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Return the full name of the agent.
|
|
52
|
+
# This method is intended to be overridden by subclasses to give better full names, tailored to the kind of agent.
|
|
53
|
+
# The full name can be used in logs and traces to better identify the agent.
|
|
54
|
+
#
|
|
55
|
+
# @return [String] The agent's full name
|
|
56
|
+
def full_name
|
|
57
|
+
"#{name || 'Unnamed'} (Cline #{@provider}/#{@model})"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @!group Internal
|
|
61
|
+
|
|
62
|
+
# Export the agent state for persistence
|
|
63
|
+
#
|
|
64
|
+
# @return [Object] Serialized state that can be marshalled to JSON
|
|
65
|
+
def export_state
|
|
66
|
+
super.merge(deep_transform_keys(context: @context, &:to_s))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Import the agent state from persistence
|
|
70
|
+
#
|
|
71
|
+
# @param state [Object] Serialized state
|
|
72
|
+
def import_state(state)
|
|
73
|
+
super
|
|
74
|
+
@context = deep_transform_keys(state, &:to_sym)[:context]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Process a user prompt.
|
|
80
|
+
#
|
|
81
|
+
# @param user_prompt [String] The rendered user prompt
|
|
82
|
+
# @return [String] The output of the prompt
|
|
83
|
+
def prompt(user_prompt)
|
|
84
|
+
# Add the context to the prompt if it is the first prompt of this Cline CLI session
|
|
85
|
+
full_user_prompt =
|
|
86
|
+
if @context.empty?
|
|
87
|
+
user_prompt
|
|
88
|
+
else
|
|
89
|
+
<<~EO_SECTION
|
|
90
|
+
# Previous sessions context
|
|
91
|
+
|
|
92
|
+
Here is the conversation from a previous session for context:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
#{JSON.dump(@context)}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Continue with the task, building on the work from the session above.
|
|
99
|
+
|
|
100
|
+
#{user_prompt}
|
|
101
|
+
EO_SECTION
|
|
102
|
+
end
|
|
103
|
+
# Call the Cline CLI
|
|
104
|
+
result = cline_cli.task(
|
|
105
|
+
full_user_prompt,
|
|
106
|
+
system: @system_prompt,
|
|
107
|
+
on_question: respond_to?(:ask, true) ? proc { |question| ask(question) } : nil,
|
|
108
|
+
on_message: proc do |message, _last, _previous_version|
|
|
109
|
+
# Look for any output artifact from the answers
|
|
110
|
+
if message.role == 'assistant'
|
|
111
|
+
message.content&.each do |content|
|
|
112
|
+
parse_output_artifacts(content.text) if content.type == 'text' && content.text
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end,
|
|
116
|
+
**@cli_options
|
|
117
|
+
)
|
|
118
|
+
raise "Error: #{result[:error].err.message}".strip if result[:error]
|
|
119
|
+
|
|
120
|
+
# Keep the context in case we need to resume it
|
|
121
|
+
if cline_cli.session&.messages
|
|
122
|
+
@context.concat(
|
|
123
|
+
cline_cli.session.messages.map.with_index do |message, idx_message|
|
|
124
|
+
{
|
|
125
|
+
role: message.role,
|
|
126
|
+
content: message.content.map.with_index do |content, idx_content|
|
|
127
|
+
{
|
|
128
|
+
type: content.type,
|
|
129
|
+
text: (
|
|
130
|
+
if idx_message.zero? && idx_content.zero?
|
|
131
|
+
# First message is the user prompt, but we don't want to include the context from it
|
|
132
|
+
user_prompt
|
|
133
|
+
else
|
|
134
|
+
content.text
|
|
135
|
+
end
|
|
136
|
+
),
|
|
137
|
+
input:
|
|
138
|
+
if content.input
|
|
139
|
+
{
|
|
140
|
+
question: content.input.question,
|
|
141
|
+
options: content.input.options
|
|
142
|
+
}.compact
|
|
143
|
+
end,
|
|
144
|
+
content: content.content
|
|
145
|
+
}.compact
|
|
146
|
+
end
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
result[:message]&.content&.last&.text || ''
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Get the Cline CLI instance to use for this agent.
|
|
156
|
+
# Memoize it.
|
|
157
|
+
#
|
|
158
|
+
# @return [::Cline::Cli] The Cline CLI instance to be used
|
|
159
|
+
def cline_cli
|
|
160
|
+
@cline_cli ||= begin
|
|
161
|
+
# Resolve all the skills and their dependencies (taken from their YAML front matter).
|
|
162
|
+
selected_skills = []
|
|
163
|
+
@skills.each do |skill|
|
|
164
|
+
find_skill(skill, selected_skills)
|
|
165
|
+
end
|
|
166
|
+
# Setup the temporary Cline global config dir
|
|
167
|
+
agent_tmp_dir = "#{@composable_agents_dir}/tmp/#{Time.now.utc.strftime('%F-%H-%M-%S')}#{"-#{name.gsub(/[^\w.]/, '_')}" if name}"
|
|
168
|
+
::Cline.configure do |config|
|
|
169
|
+
config.debug = Mixins::Logger.debug?
|
|
170
|
+
config.temp_dir_root = "#{agent_tmp_dir}/cline-rb"
|
|
171
|
+
end
|
|
172
|
+
cline_config = ::Cline::Config.open("#{agent_tmp_dir}/cline_config", create: true)
|
|
173
|
+
# Copy all selected global skills in this config's skills
|
|
174
|
+
if ::Cline::Config.global&.skills
|
|
175
|
+
(::Cline::Config.global.skills.keys & selected_skills).each do |skill_name|
|
|
176
|
+
new_skill = cline_config.skills.new(skill_name)
|
|
177
|
+
new_skill.files.replace(::Cline::Config.global.skills[skill_name].files)
|
|
178
|
+
new_skill.enable
|
|
179
|
+
log_debug "[Cline] - Enable global skill #{skill_name}"
|
|
180
|
+
new_skill.save
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
# Enable/disable project skills to make sure only selected ones are enabled
|
|
184
|
+
::Cline::Config.project&.skills&.each do |skill_name, skill|
|
|
185
|
+
selected_skill = selected_skills.include?(skill_name)
|
|
186
|
+
next if skill.enabled? == selected_skill
|
|
187
|
+
|
|
188
|
+
if selected_skill
|
|
189
|
+
log_debug "[Cline] - Enable project skill #{skill_name}"
|
|
190
|
+
skill.enable
|
|
191
|
+
else
|
|
192
|
+
log_debug "[Cline] - Disable project skill #{skill_name}"
|
|
193
|
+
skill.disable
|
|
194
|
+
end
|
|
195
|
+
skill.save
|
|
196
|
+
end
|
|
197
|
+
# TODO: When using skillkit, also create a global rule only containing selected skills (use skillkit if possible to generate it).
|
|
198
|
+
# Or maybe just don't use skillkit to generate this AGENTS.md file. Skills should be discoverable without it. To be tested.
|
|
199
|
+
# Set the configuration
|
|
200
|
+
providers = cline_config.providers(create: true)
|
|
201
|
+
providers.version = 1
|
|
202
|
+
providers.last_used_provider = @provider
|
|
203
|
+
providers.providers = {
|
|
204
|
+
@provider => {
|
|
205
|
+
token_source: 'manual',
|
|
206
|
+
updated_at: Time.now.utc.strftime('%FT%T.%LZ'),
|
|
207
|
+
settings: {
|
|
208
|
+
provider: @provider,
|
|
209
|
+
api_key: @api_key,
|
|
210
|
+
model: @model
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
@configure_provider&.call(providers.providers[@provider].settings)
|
|
215
|
+
providers.save
|
|
216
|
+
global_settings = cline_config.global_settings(create: true)
|
|
217
|
+
# Don't update the Cline CLI
|
|
218
|
+
global_settings.auto_update_enabled = false
|
|
219
|
+
@configure_global&.call(global_settings)
|
|
220
|
+
global_settings.save
|
|
221
|
+
cline_config.cli(stdout_echo: Mixins::Logger.debug?, verbose: Mixins::Logger.debug?)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Find a skill among the current Cline environment (global and project) and recursively finds all its dependencies.
|
|
226
|
+
#
|
|
227
|
+
# @param skill [String] The skill name we are looking for
|
|
228
|
+
# @param found_skills [Array<String>] In place list of skills that are already found (so we don't need to look for them again)
|
|
229
|
+
# If found, the skill and its dependencies will be added to this array.
|
|
230
|
+
def find_skill(skill, found_skills)
|
|
231
|
+
return if found_skills.include?(skill)
|
|
232
|
+
|
|
233
|
+
# Find the skill among the global or local configs
|
|
234
|
+
found_skill = (::Cline::Config.global&.skills && ::Cline::Config.global.skills[skill]) ||
|
|
235
|
+
(::Cline::Config.project&.skills && ::Cline::Config.project.skills[skill])
|
|
236
|
+
raise MissingSkillError, "Cline Skill #{skill} is unknown, neither in the global nor project configurations" unless found_skill
|
|
237
|
+
|
|
238
|
+
found_skills << skill
|
|
239
|
+
# Now look for its dependencies
|
|
240
|
+
(found_skill.yaml_front_matter.dig(*%i[metadata dependencies]) || []).each do |skill_dep|
|
|
241
|
+
find_skill(skill_dep, found_skills)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module ComposableAgents
|
|
2
|
+
# Provide a way to define instructions to be used by system and user prompts.
|
|
3
|
+
# Instructions are always normalized as a list of individual instructions that each can be rendered differently depending on the rendering strategy.
|
|
4
|
+
# This is used by PromptDrivenAgent agents only.
|
|
5
|
+
class Instructions
|
|
6
|
+
# @!group Public API
|
|
7
|
+
|
|
8
|
+
include Enumerable
|
|
9
|
+
|
|
10
|
+
# Constructor
|
|
11
|
+
#
|
|
12
|
+
# @param instructions [Object] The instructions definition. Here are the possible kinds of system instructions:
|
|
13
|
+
# - [Array<Object>] List of instruction descriptions that should be appended
|
|
14
|
+
# - [Object] Individual instruction description.
|
|
15
|
+
# An individual instruction can be one of the following:
|
|
16
|
+
# - [String] Direct instructions to be used (equivalent to { text: instructions })
|
|
17
|
+
# - [Hash\\{Symbol => Object}] A structure describing the instructions
|
|
18
|
+
# Here is the list of keys that can define different instructions:
|
|
19
|
+
# - text [String] The instructions are given as text directly.
|
|
20
|
+
# - ordered_list [Array<String>] The instructions are a precise list of steps to perform.
|
|
21
|
+
# Several keys can be used in the same Hash, and they will be treated in the order of the Hash.
|
|
22
|
+
def initialize(instructions)
|
|
23
|
+
# Normalize system instructions to [Array<Hash{Symbol => Object}>].
|
|
24
|
+
@instructions = (instructions.is_a?(Array) ? instructions : [instructions]).map do |instructions_set|
|
|
25
|
+
instructions_set.is_a?(Hash) ? instructions_set : { text: instructions_set }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Iterate over all instructions
|
|
30
|
+
#
|
|
31
|
+
# @yield [instruction_type, instruction] Each instruction present in these instructions.
|
|
32
|
+
# @yieldparam instruction_type [Symbol] The instruction type.
|
|
33
|
+
# @yieldparam instruction [Object] The instruction itself.
|
|
34
|
+
def each(&)
|
|
35
|
+
return enum_for(:each) unless block_given?
|
|
36
|
+
|
|
37
|
+
@instructions.each do |instructions_set|
|
|
38
|
+
instructions_set.each(&)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module ComposableAgents
|
|
2
|
+
module Mixins
|
|
3
|
+
# Mixin that adds user interaction capabilities to AiAgent::Agent agents.
|
|
4
|
+
# This uses an AiAgent tool to then call the normal UserInteraction interface.
|
|
5
|
+
module AiAgentUserInteraction
|
|
6
|
+
# @!group Internal
|
|
7
|
+
|
|
8
|
+
# Hook used when this mixin is included in a base class
|
|
9
|
+
#
|
|
10
|
+
# @param base [Class] The base class
|
|
11
|
+
def self.prepended(base)
|
|
12
|
+
base.include(UserInteraction)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns the list of tools available for this agent
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<Agents::Tool>] List of tools
|
|
18
|
+
def agent_tools
|
|
19
|
+
super + [AiAgents::Tools::AskUserTool.new(self)]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
|
|
3
|
+
module ComposableAgents
|
|
4
|
+
module Mixins
|
|
5
|
+
# Mixin providing input/output artifact validation functionality for Agents.
|
|
6
|
+
# The contracts should be provided by methods named #input_artifacts_contracts and #output_artifacts_contracts
|
|
7
|
+
# A contract can be one of the following objects:
|
|
8
|
+
# - [String] The artifact's description
|
|
9
|
+
# - [Hash\\{Symbol => Object}] The artifact's detailed contract. It can contain the following attributes:
|
|
10
|
+
# - description [String] The artifact's description. This is also the default value when the contract is expressed as a [String].
|
|
11
|
+
# - optional [Boolean] Is the artifact optional? Defaults to false.
|
|
12
|
+
# - type [Symbol] The type of this artifact. Defaults to :text.
|
|
13
|
+
# Possible values are:
|
|
14
|
+
# - `text` for raw text.
|
|
15
|
+
# - `markdown` for Markdown.
|
|
16
|
+
# - `json` for JSON.
|
|
17
|
+
# Type can be used by some agents to tailor their output format and prompts.
|
|
18
|
+
module ArtifactContract
|
|
19
|
+
# @!group Public API
|
|
20
|
+
|
|
21
|
+
# Raised when required input artifacts are missing
|
|
22
|
+
class MissingInputArtifactError < RuntimeError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised when expected output artifacts are missing
|
|
26
|
+
class MissingOutputArtifactError < RuntimeError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Raised when validations on artifact types are failing
|
|
30
|
+
class ArtifactTypeError < RuntimeError
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Constructor
|
|
34
|
+
#
|
|
35
|
+
# @param input_artifacts_contracts [Hash{Symbol => Object}, nil] Hash of input artifact names and their contracts,
|
|
36
|
+
# or nil if provided through the input_artifacts_contracts method
|
|
37
|
+
# @param output_artifacts_contracts [Hash{Symbol => Object}, nil] Hash of output artifact names and their contracts,
|
|
38
|
+
# or nil if provided through the output_artifacts_contracts method
|
|
39
|
+
def initialize(
|
|
40
|
+
*args,
|
|
41
|
+
input_artifacts_contracts: nil,
|
|
42
|
+
output_artifacts_contracts: nil,
|
|
43
|
+
**kwargs
|
|
44
|
+
)
|
|
45
|
+
super(*args, **kwargs)
|
|
46
|
+
@input_artifacts_contracts = input_artifacts_contracts
|
|
47
|
+
@output_artifacts_contracts = output_artifacts_contracts
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @!group Internal
|
|
51
|
+
|
|
52
|
+
# Execute the agent to generate some output artifacts based on some input artifacts.
|
|
53
|
+
#
|
|
54
|
+
# @param input_artifacts [Hash{Symbol => Object}] The input artifacts content
|
|
55
|
+
# @return [Hash{Symbol => Object}] Output artifacts content
|
|
56
|
+
# @raise [MissingInputArtifactError] If required input artifacts are missing
|
|
57
|
+
# @raise [MissingOutputArtifactError] If expected output artifacts are missing after run
|
|
58
|
+
def run(**input_artifacts)
|
|
59
|
+
validate_input_artifacts(input_artifacts)
|
|
60
|
+
output_artifacts = super(**input_artifacts.slice(*normalized_input_artifacts_contracts.keys))
|
|
61
|
+
validate_output_artifacts(output_artifacts)
|
|
62
|
+
output_artifacts
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Define input artifacts contracts
|
|
68
|
+
#
|
|
69
|
+
# @return [Hash{Symbol => Object}] Set of input artifacts contract, per artifact name
|
|
70
|
+
def input_artifacts_contracts
|
|
71
|
+
# If the contracts were given by the constructor, use them
|
|
72
|
+
(defined?(super) ? super : {}).merge(@input_artifacts_contracts || {})
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Define output artifacts contracts
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash{Symbol => Object}] Set of output artifacts contract, per artifact name
|
|
78
|
+
def output_artifacts_contracts
|
|
79
|
+
# If the contracts were given by the constructor, use them
|
|
80
|
+
(defined?(super) ? super : {}).merge(@output_artifacts_contracts || {})
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Normalize artifacts' contracts
|
|
84
|
+
#
|
|
85
|
+
# @param artifacts_contracts [Hash{Symbol => Object}] The artifacts contracts to be normalized
|
|
86
|
+
# @return [Hash{Symbol => Hash{Symbol => Object}}] The normalized artifacts contracts (always in their Hash form as described in the class documentation)
|
|
87
|
+
def normalize_contracts(artifacts_contracts)
|
|
88
|
+
artifacts_contracts.to_h do |art_name, contract_def|
|
|
89
|
+
contract_def = { description: contract_def } unless contract_def.is_a?(Hash)
|
|
90
|
+
[
|
|
91
|
+
art_name,
|
|
92
|
+
# Default values
|
|
93
|
+
{
|
|
94
|
+
description: 'Artifact',
|
|
95
|
+
optional: false
|
|
96
|
+
}.merge(contract_def)
|
|
97
|
+
]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Retrieve and memoize normalized input artifacts contracts
|
|
102
|
+
#
|
|
103
|
+
# @return [Hash{Symbol => Hash{Symbol => Object}}] Set of normalized input artifacts contract, per artifact name
|
|
104
|
+
def normalized_input_artifacts_contracts
|
|
105
|
+
@normalized_input_artifacts_contracts ||= normalize_contracts(input_artifacts_contracts)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Retrieve and memoize normalized output artifacts contracts
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash{Symbol => Hash{Symbol => Object}}] Set of normalized output artifacts contract, per artifact name
|
|
111
|
+
def normalized_output_artifacts_contracts
|
|
112
|
+
@normalized_output_artifacts_contracts ||= normalize_contracts(output_artifacts_contracts)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Validate that all required input artifacts are present
|
|
116
|
+
#
|
|
117
|
+
# @param artifacts [Hash{Symbol => Object}] Input artifacts to validate
|
|
118
|
+
# @raise [MissingInputArtifactError] If any required artifacts are missing
|
|
119
|
+
def validate_input_artifacts(artifacts)
|
|
120
|
+
artifacts_contracts = normalized_input_artifacts_contracts.reject { |_name, contract| contract[:optional] }
|
|
121
|
+
missing_inputs = artifacts_contracts.keys - artifacts.keys
|
|
122
|
+
unless missing_inputs.empty?
|
|
123
|
+
raise MissingInputArtifactError, "Missing required input artifacts:\n#{
|
|
124
|
+
missing_inputs.map do |artifact_name|
|
|
125
|
+
"* #{artifact_name}: #{artifacts_contracts[artifact_name][:description]}"
|
|
126
|
+
end.join("\n")
|
|
127
|
+
}"
|
|
128
|
+
end
|
|
129
|
+
validate_artifacts_types(artifacts, normalized_input_artifacts_contracts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Validate that all expected output artifacts are present
|
|
133
|
+
#
|
|
134
|
+
# @param artifacts [Hash{Symbol => Object}] Output artifacts to validate
|
|
135
|
+
# @raise [MissingOutputArtifactError] If any expected artifacts are missing
|
|
136
|
+
def validate_output_artifacts(artifacts)
|
|
137
|
+
artifacts_contracts = normalized_output_artifacts_contracts.reject { |_name, contract| contract[:optional] }
|
|
138
|
+
missing_outputs = artifacts_contracts.keys - artifacts.keys
|
|
139
|
+
unless missing_outputs.empty?
|
|
140
|
+
raise MissingOutputArtifactError, "Agent failed to produce expected output artifacts:\n#{
|
|
141
|
+
missing_outputs.map do |key|
|
|
142
|
+
"* #{key}: #{artifacts_contracts[key][:description]}"
|
|
143
|
+
end.join("\n")
|
|
144
|
+
}"
|
|
145
|
+
end
|
|
146
|
+
validate_artifacts_types(artifacts, normalized_output_artifacts_contracts)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate the types of the artifacts.
|
|
150
|
+
#
|
|
151
|
+
# @param artifacts [Hash{Symbol => Object}] The artifacts to validate
|
|
152
|
+
# @param artifacts_contracts [Hash{Symbol => Hash{Symbol => Object}}] The artifacts contracts
|
|
153
|
+
def validate_artifacts_types(artifacts, artifacts_contracts)
|
|
154
|
+
# Validate the types if provided
|
|
155
|
+
artifacts.each do |artifact_name, content|
|
|
156
|
+
required_type = artifacts_contracts.dig(artifact_name, :type)
|
|
157
|
+
next unless required_type
|
|
158
|
+
|
|
159
|
+
case required_type
|
|
160
|
+
when :text, :markdown
|
|
161
|
+
unless content.is_a?(String)
|
|
162
|
+
raise ArtifactTypeError, "Artifact #{artifact_name} should be a #{required_type} String but is actually a #{content.class.name}"
|
|
163
|
+
end
|
|
164
|
+
when :json
|
|
165
|
+
begin
|
|
166
|
+
unless JSON.parse(JSON.dump(content)) == content
|
|
167
|
+
raise ArtifactTypeError, "Artifact #{artifact_name} should be a JSON object but serializing it into JSON changed its data"
|
|
168
|
+
end
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
raise if e.is_a?(ArtifactTypeError)
|
|
171
|
+
|
|
172
|
+
raise ArtifactTypeError, "Artifact #{artifact_name} should be a JSON object but parsing it raised error: #{e}"
|
|
173
|
+
end
|
|
174
|
+
else
|
|
175
|
+
raise ArtifactTypeError, "Unknown artifact type: #{required_type}"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module ComposableAgents
|
|
2
|
+
# Collection of useful mixins that add functionality to agents prepending them.
|
|
3
|
+
module Mixins
|
|
4
|
+
# Logging mixin for agents
|
|
5
|
+
module Logger
|
|
6
|
+
# @!group Public API
|
|
7
|
+
|
|
8
|
+
# Check if debug mode is enabled
|
|
9
|
+
#
|
|
10
|
+
# @return [Boolean] True if debug mode is enabled
|
|
11
|
+
def self.debug?
|
|
12
|
+
ENV['COMPOSABLE_AGENTS_DEBUG'] == '1'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Log debug message only if debug mode is enabled
|
|
18
|
+
#
|
|
19
|
+
# @param message [String, Proc] Message string or Proc returning message for lazy evaluation
|
|
20
|
+
def log_debug(message)
|
|
21
|
+
return unless Logger.debug?
|
|
22
|
+
|
|
23
|
+
log(message, severity: :debug)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Log info message
|
|
27
|
+
#
|
|
28
|
+
# @param message [String, #call => String] Message string or Proc returning message for lazy evaluation
|
|
29
|
+
def log_info(message)
|
|
30
|
+
log(message, severity: :info)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Log a message with severity
|
|
34
|
+
#
|
|
35
|
+
# @param message [String, #call => String] Message string or Proc returning message for lazy evaluation
|
|
36
|
+
# @param severity [Symbol] Severity
|
|
37
|
+
def log(message, severity: :info)
|
|
38
|
+
fields = [
|
|
39
|
+
Time.now.utc.strftime('%F %T'),
|
|
40
|
+
severity.to_s.upcase
|
|
41
|
+
]
|
|
42
|
+
fields.concat(log_fields) if respond_to?(:log_fields, true)
|
|
43
|
+
puts "#{fields.map { |field| "[#{field}]" }.join(' ')} - #{message.is_a?(String) ? message : message.call}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|