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,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