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,301 @@
1
+ require 'json'
2
+
3
+ module ComposableAgents
4
+ module PromptRenderingStrategy
5
+ # Render prompt as Markdown documents with a lot of emphasis for complex agentic systems.
6
+ # This prompt strategy needs to be used conjointly with the ArtifactContract mixin.
7
+ # This mixin also adds the following methods to be used:
8
+ # - `#parse_output_artifacts(text)` Parses a text that comes from the agent to retrieve output artifacts from it.
9
+ # - `#artifact_ref(artifact_name) -> String` Returns the artifact name as seen by the assistant.
10
+ # This can be used to refer to the artifact names properly in the user or system instructions.
11
+ module MarkdownHeavy
12
+ # @!group Internal
13
+
14
+ include Markdown
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
+ return '' if instruction.empty?
22
+
23
+ checklist_name = "#{name || 'Agent'} Execution Checklist"
24
+ <<~EO_INSTRUCTIONS
25
+ Always follow all those sequential steps.
26
+
27
+ # 1. Create the #{checklist_name} (MANDATORY)
28
+
29
+ - Before executing anything, create a checklist named #{checklist_name} with all steps of these instructions.
30
+ - Do not create files to track this checklist: keep it in your memory.
31
+ - The #{checklist_name} must include all numbered steps explicitly.
32
+ - After completing each step of these instructions, mark the item in the #{checklist_name} as completed.
33
+ - Do not skip any item.
34
+ - If an item cannot be executed, explicitly explain why.
35
+ - Never mark the task as completed while any item from the #{checklist_name} remains open.
36
+
37
+ #{instruction.map.with_index { |step, step_idx| "# #{step_idx + 2}. #{Utils::Markdown.align_markdown_headers(step, level: 2).strip}" }.join("\n\n")}
38
+
39
+ # #{instruction.size + 2}. Final Verification (MANDATORY)
40
+
41
+ Before declaring the task complete:
42
+
43
+ - Re-list all numbered steps from the #{checklist_name}.
44
+ - Confirm each one was executed.
45
+ - If any step was not executed, execute it now.
46
+ EO_INSTRUCTIONS
47
+ end
48
+
49
+ # Render the system prompt
50
+ # The following instance variables are accessible to render the prompt:
51
+ # - `@input_artifacts`
52
+ # - `@role`
53
+ # - `@objective`
54
+ # - `@constraints`
55
+ #
56
+ # @param rendered_instructions [String, nil] The rendered system instructions, or nil if none
57
+ # @return [String] The rendered system prompt
58
+ def render_system_prompt(rendered_instructions)
59
+ sections = [super]
60
+ # Don't document the user instructions themselves
61
+ input_contracts = normalized_input_artifacts_contracts.except(:user_instructions)
62
+ unless input_contracts.empty?
63
+ sections << <<~EO_SECTION
64
+ # Input artifacts' concept and usage
65
+
66
+ - Artifacts are documents that you can get as input.
67
+ - Each artifact is identified by a name, like `#{artifact_ref(:plan)}`.
68
+ - You must consider all artifacts given in the "Input artifacts" section of the user prompt.
69
+ - All artifacts presented in the "Input artifacts" section of the user prompt provide their content in an in-line JSON document.
70
+ #{
71
+ input_contracts.map do |artifact_name, artifact_contract|
72
+ art_ref = artifact_ref(artifact_name)
73
+ [
74
+ "- The content of input artifact `#{art_ref}` describes this: #{artifact_contract[:description]}"
75
+ ] + (
76
+ if artifact_contract[:optional]
77
+ ["- The input artifact `#{art_ref}` is optional and may not be given to you."]
78
+ else
79
+ ["- The input artifact `#{art_ref}` is expected to be in the user prompt."]
80
+ end
81
+ ) + [
82
+ "- The input artifact `#{art_ref}` artifact content is embedded directly in the user prompt as in-line JSON. It is NOT a file. Do NOT try to open it as a file."
83
+ ]
84
+ end.compact.flatten(1).join("\n")
85
+ }
86
+ EO_SECTION
87
+ end
88
+ unless normalized_output_artifacts_contracts.empty?
89
+ sections << <<~EO_SECTION
90
+ # Output artifacts' concept and usage
91
+
92
+ - The user will ask you to provide some artifacts as output.
93
+ - You must always return the required artifact as a JSON document in your response, with its name in the JSON header, like this:
94
+ ```json output_artifact=#{artifact_ref(:name)}
95
+ {artifact_content}
96
+ ```
97
+ - Do not create files for output artifacts: always give them inside embedded JSON in your last response.
98
+ - Always return output artifacts that the user is asking you to provide.
99
+ - You can return several output artifacts in your responses if needed.
100
+
101
+ Following sections enumerate all expected output artifacts.
102
+
103
+ #{
104
+ normalized_output_artifacts_contracts.map do |artifact_name, artifact_contract|
105
+ art_ref = artifact_ref(artifact_name)
106
+ (
107
+ [
108
+ "## Output artifact `#{art_ref}`",
109
+ '',
110
+ "- The content of output artifact `#{art_ref}` should describe this: #{artifact_contract[:description]}"
111
+ ] + (
112
+ artifact_contract[:type] ? ["- The output artifact `#{art_ref}` content format should be #{artifact_contract[:type]}"] : []
113
+ ) + [
114
+ <<~EO_ITEM.strip
115
+ - The output artifact `#{art_ref}` should be given in a block like this:
116
+ #{example_json_block(artifact_name)}
117
+ EO_ITEM
118
+ ]
119
+ ).join("\n")
120
+ end.join("\n\n")
121
+ }
122
+ EO_SECTION
123
+ end
124
+ sections.map(&:strip).join("\n\n")
125
+ end
126
+
127
+ # Render the user prompt
128
+ # The following instance variables are accessible to render the prompt:
129
+ # - `@role`
130
+ # - `@objective`
131
+ # - `@constraints`
132
+ #
133
+ # @param rendered_instructions [String, nil] The rendered instructions, or nil if none
134
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content for which we render this prompt, per artifact name
135
+ # @return [String] The rendered user prompt
136
+ def render_user_prompt(rendered_instructions, input_artifacts:)
137
+ sections = []
138
+ unless input_artifacts.empty?
139
+ sections << <<~EO_SECTION.strip
140
+ # Input artifacts
141
+
142
+ #{
143
+ input_artifacts.map do |artifact_name, artifact_content|
144
+ art_ref = artifact_ref(artifact_name)
145
+ <<~EO_ARTIFACT_SECTION.strip
146
+ ## `#{art_ref}`
147
+
148
+ ```json input_artifact=#{art_ref}
149
+ #{JSON.dump(artifact_content)}
150
+ ```
151
+ EO_ARTIFACT_SECTION
152
+ end.join("\n\n")
153
+ }
154
+ EO_SECTION
155
+ end
156
+ sections << <<~EO_SECTION if rendered_instructions && !rendered_instructions.empty?
157
+ # User instructions
158
+
159
+ #{Utils::Markdown.align_markdown_headers(rendered_instructions, level: 2)}
160
+ EO_SECTION
161
+ sections.map(&:strip).join("\n\n")
162
+ end
163
+
164
+ # Get user instructions for missing output artifacts
165
+ #
166
+ # @param missing_output_artifacts [Hash{Symbol => Object}] The missing output artifacts information, per artifact name
167
+ # Information can contain the following attributes:
168
+ # - description [String] The artifact's description.
169
+ # - error [String, nil] An error message related to this missing artifact.
170
+ # @return [Object] The user instructions (see Instructions#initialize)
171
+ def missing_output_user_instructions(missing_output_artifacts)
172
+ log_debug "[Artifact] - Asking assistant for missing output artifacts `#{missing_output_artifacts.keys.join(', ')}` to be returned in its next answer."
173
+ <<~EO_PROMPT
174
+ The following output artifacts are missing from your previous responses:
175
+ #{
176
+ missing_output_artifacts.map do |artifact_name, missing_info|
177
+ ["- `#{artifact_ref(artifact_name)}`: #{missing_info[:description]}"] +
178
+ (missing_info[:error] ? [" An error occurred while reading this artifact from your previous responses: #{missing_info[:error]}"] : [])
179
+ end.flatten(1).join("\n")
180
+ }
181
+
182
+ You must provide each one of them in your next response using embedded JSON blocks like this:
183
+
184
+ #{
185
+ missing_output_artifacts.keys.map { |artifact_name| example_json_block(artifact_name) }.join("\n\n")
186
+ }
187
+
188
+ - You must return all those artifacts in your next response (MANDATORY).
189
+ EO_PROMPT
190
+ end
191
+
192
+ # Get the artifact reference name communicated to the assistant
193
+ #
194
+ # @param artifact_name [Symbol] The artifact name
195
+ # @return [String] The artifact reference name used for the assistant
196
+ def artifact_ref(artifact_name)
197
+ "ARTIFACT_#{artifact_name.to_s.upcase}"
198
+ end
199
+
200
+ # Parse some text to find output artifacts in it.
201
+ #
202
+ # @param text [String] The text to be parsed
203
+ def parse_output_artifacts(text)
204
+ # Scan for JSON documents with artifact markers in the format:
205
+ # ```json output_artifact=ARTIFACT_<NAME>
206
+ # {artifact_content}
207
+ # ```
208
+ text.scan(/```json\s+output_artifact=(\S+)\n(.*?)```(?=\n|\z)/m) do
209
+ art_ref = Regexp.last_match(1)
210
+ content = Regexp.last_match(2).strip
211
+ # Convert the assistant artifact name (e.g. ARTIFACT_PLAN) back to a symbol (e.g. :plan)
212
+ artifact_name = (art_ref.start_with?('ARTIFACT_') ? art_ref.sub(/^ARTIFACT_/, '') : art_ref).downcase.to_sym
213
+ artifact_json_content = nil
214
+ begin
215
+ artifact_json_content = JSON.parse(content)
216
+ rescue JSON::ParserError => e
217
+ report_error_for_output_artifact(artifact_name, e.to_s)
218
+ next
219
+ end
220
+ # Use the type info to better parse the JSON into the real artifact content
221
+ artifact_type = normalized_output_artifacts_contracts.dig(artifact_name, :type)
222
+ artifact_content =
223
+ case artifact_type
224
+ when nil, :json
225
+ artifact_json_content
226
+ when :text
227
+ if artifact_json_content.key?('text')
228
+ if artifact_json_content['text'].is_a?(String)
229
+ artifact_json_content['text']
230
+ else
231
+ report_error_for_output_artifact(
232
+ artifact_name,
233
+ 'Wrong format for artifact content in key "text": ' \
234
+ "expecting a raw String but got #{artifact_json_content['text'].class.name} instead."
235
+ )
236
+ nil
237
+ end
238
+ else
239
+ report_error_for_output_artifact(
240
+ artifact_name,
241
+ 'Missing required key "text" containing the artifact text content in the JSON artifact response.'
242
+ )
243
+ nil
244
+ end
245
+ when :markdown
246
+ if artifact_json_content.key?('markdown')
247
+ if artifact_json_content['markdown'].is_a?(String)
248
+ artifact_json_content['markdown']
249
+ else
250
+ report_error_for_output_artifact(
251
+ artifact_name,
252
+ 'Wrong format for artifact content in key "markdown": ' \
253
+ "expecting a Markdown string but got #{artifact_json_content['markdown'].class.name} instead."
254
+ )
255
+ nil
256
+ end
257
+ else
258
+ report_error_for_output_artifact(
259
+ artifact_name,
260
+ 'Missing required key "markdown" containing the artifact Markdown content in the JSON artifact response.'
261
+ )
262
+ nil
263
+ end
264
+ else
265
+ raise "Unknown artifact type: #{artifact_type}"
266
+ end
267
+ save_output_artifact(artifact_name, artifact_content) if artifact_content
268
+ end
269
+ end
270
+
271
+ private
272
+
273
+ # Provide a Markdown example of a JSON block for a given output artifact
274
+ #
275
+ # @param artifact_name [Symbol] The output artifact name
276
+ # @return [String] Corresponding Markdown example
277
+ def example_json_block(artifact_name)
278
+ art_ref = artifact_ref(artifact_name)
279
+ artifact_type = normalized_output_artifacts_contracts.dig(artifact_name, :type)
280
+ <<~EO_MARKDOWN.strip
281
+ ```json output_artifact=#{art_ref}
282
+ #{
283
+ case artifact_type
284
+ when nil
285
+ "#{art_ref}_content"
286
+ when :text
287
+ "{\"text\":\"#{art_ref}_raw_text_content\"}"
288
+ when :markdown
289
+ "{\"markdown\":\"#{art_ref}_markdown_content\"}"
290
+ when :json
291
+ "{#{art_ref}_json_content}"
292
+ else
293
+ raise "Unknown artifact type: #{artifact_type}"
294
+ end
295
+ }
296
+ ```
297
+ EO_MARKDOWN
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,28 @@
1
+ module ComposableAgents
2
+ # Agent implementation that wraps a Ruby Proc for custom logic
3
+ #
4
+ # This agent allows wrapping arbitrary Ruby logic as an Agent by providing
5
+ # a Proc that handles the transformation of input artifacts to output artifacts.
6
+ class RubyAgent < Agent
7
+ # @!group Public API
8
+
9
+ # Initialize a new RubyAgent with a processing proc
10
+ #
11
+ # @param processor [#call(Hash{Symbol => Object}) => Hash{Symbol => Object}] The agent logic.
12
+ # This proc will receive the input artifacts hash and must return a hash of output artifacts.
13
+ # - Param input_artifacts [Hash\\{Symbol => Object}] Input artifacts provided to the agent
14
+ # - Return [Hash\\{Symbol => Object}] Output artifacts produced by the agent
15
+ def initialize(processor, *args, **kwargs)
16
+ super(*args, **kwargs)
17
+ @processor = processor
18
+ end
19
+
20
+ # Execute the agent to generate some output artifacts based on some input artifacts.
21
+ #
22
+ # @param input_artifacts [Hash{Symbol => Object}] The input artifacts content, per artifact name
23
+ # @return [Hash{Symbol => Object}] The output artifacts returned by the Proc
24
+ def run(**input_artifacts)
25
+ @processor.call(input_artifacts)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,56 @@
1
+ require 'commonmarker'
2
+
3
+ module ComposableAgents
4
+ # Various internal helpers and utilities
5
+ module Utils
6
+ # Internal util methods to handle Markdown
7
+ module Markdown
8
+ class << self
9
+ # @!group Internal
10
+
11
+ # Align markdown headers in a String to a given level.
12
+ # This method parses the String as a markdown document, sees the minimum current header level,
13
+ # and changes it while preserving the structure and hierarchy so that this min level is equal to `level`.
14
+ #
15
+ # @param markdown [String] The markdown content to align
16
+ # @param level [Integer] The target level for the minimum header
17
+ # @return [String] The aligned markdown content
18
+ def align_markdown_headers(markdown, level: 2)
19
+ doc = Commonmarker.parse(markdown)
20
+ min_level = find_minimum_header_level(doc)
21
+ return markdown if min_level.nil? || min_level == level
22
+
23
+ adjust_header_levels(doc, level - min_level)
24
+ # Unescape dots in headers
25
+ # TODO: Remove this gsub when CommonMarker will be fixed.
26
+ doc.to_commonmark.gsub(/^\#{1,6}\s+\h+\K\\\. /, '. ')
27
+ end
28
+
29
+ # Find the minimum header level in a CommonMarker document
30
+ #
31
+ # @param doc [CommonMarker::Document] The parsed CommonMarker document
32
+ # @return [Integer, nil] The minimum header level found, or nil if no headers exist
33
+ def find_minimum_header_level(doc)
34
+ min_level = nil
35
+ doc.walk do |node|
36
+ if node.type == :heading
37
+ current_level = node.header_level
38
+ min_level = current_level if min_level.nil? || current_level < min_level
39
+ end
40
+ end
41
+ min_level
42
+ end
43
+
44
+ # Adjust header levels in a CommonMarker document by a given difference
45
+ #
46
+ # @param doc [CommonMarker::Document] The parsed CommonMarker document
47
+ # @param level_diff [Integer] The difference to add to each header level
48
+ def adjust_header_levels(doc, level_diff)
49
+ doc.walk do |node|
50
+ node.header_level = node.header_level + level_diff if node.type == :heading
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,6 @@
1
+ module ComposableAgents
2
+ # @!group Public API
3
+
4
+ # Gem version
5
+ VERSION = '1.0.0'
6
+ end
@@ -0,0 +1,7 @@
1
+ require 'zeitwerk'
2
+
3
+ Zeitwerk::Loader.for_gem.setup
4
+
5
+ # Main module giving all needed classes to define aomposable agents.
6
+ module ComposableAgents
7
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: composable_agents
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Muriel Salvan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ai-agents
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: cline-rb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: commonmarker
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '2.7'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '2.7'
54
+ - !ruby/object:Gem::Dependency
55
+ name: zeitwerk
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '2.7'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '2.7'
68
+ email: muriel@x-aeon.com
69
+ executables: []
70
+ extensions: []
71
+ extra_rdoc_files: []
72
+ files:
73
+ - CHANGELOG.md
74
+ - README.md
75
+ - TODO.md
76
+ - lib/composable_agents.rb
77
+ - lib/composable_agents/agent.rb
78
+ - lib/composable_agents/ai_agents/agent.rb
79
+ - lib/composable_agents/ai_agents/tools/ask_user_tool.rb
80
+ - lib/composable_agents/ai_agents/tools/create_artifact_tool.rb
81
+ - lib/composable_agents/ai_agents/tools/get_artifact_tool.rb
82
+ - lib/composable_agents/cline/agent.rb
83
+ - lib/composable_agents/instructions.rb
84
+ - lib/composable_agents/mixins/ai_agent_user_interaction.rb
85
+ - lib/composable_agents/mixins/artifact_contract.rb
86
+ - lib/composable_agents/mixins/logger.rb
87
+ - lib/composable_agents/mixins/resumable.rb
88
+ - lib/composable_agents/mixins/user_interaction.rb
89
+ - lib/composable_agents/prompt_driven_agent.rb
90
+ - lib/composable_agents/prompt_rendering_strategy/markdown.rb
91
+ - lib/composable_agents/prompt_rendering_strategy/markdown_heavy.rb
92
+ - lib/composable_agents/ruby_agent.rb
93
+ - lib/composable_agents/utils/markdown.rb
94
+ - lib/composable_agents/version.rb
95
+ homepage: https://github.com/Muriel-Salvan/composable_agents
96
+ licenses:
97
+ - BSD-3-Clause
98
+ metadata:
99
+ rubygems_mfa_required: 'true'
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '3.1'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.6.9
115
+ specification_version: 4
116
+ summary: Composable AI agents framework
117
+ test_files: []