ruby_llm-agents 0.4.0 → 1.0.0.beta.1

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.
Files changed (208) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +225 -34
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +136 -16
  4. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +29 -9
  6. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  7. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  8. data/app/controllers/ruby_llm/agents/workflows_controller.rb +355 -0
  9. data/app/helpers/ruby_llm/agents/application_helper.rb +25 -0
  10. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  11. data/app/models/ruby_llm/agents/execution.rb +3 -0
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +112 -14
  13. data/app/services/ruby_llm/agents/agent_registry.rb +51 -12
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +5 -30
  15. data/app/views/ruby_llm/agents/agents/_agent.html.erb +13 -1
  16. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +235 -0
  17. data/app/views/ruby_llm/agents/agents/_config_embedder.html.erb +70 -0
  18. data/app/views/ruby_llm/agents/agents/_config_image_generator.html.erb +152 -0
  19. data/app/views/ruby_llm/agents/agents/_config_moderator.html.erb +63 -0
  20. data/app/views/ruby_llm/agents/agents/_config_speaker.html.erb +108 -0
  21. data/app/views/ruby_llm/agents/agents/_config_transcriber.html.erb +91 -0
  22. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +1 -1
  23. data/app/views/ruby_llm/agents/agents/index.html.erb +74 -9
  24. data/app/views/ruby_llm/agents/agents/show.html.erb +18 -378
  25. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  26. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  27. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  28. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  30. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  31. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  32. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +269 -15
  33. data/app/views/ruby_llm/agents/executions/show.html.erb +98 -0
  34. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +93 -0
  35. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  37. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  38. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  39. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  40. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +236 -0
  41. data/app/views/ruby_llm/agents/workflows/_structure_parallel.html.erb +76 -0
  42. data/app/views/ruby_llm/agents/workflows/_structure_pipeline.html.erb +74 -0
  43. data/app/views/ruby_llm/agents/workflows/_structure_router.html.erb +108 -0
  44. data/app/views/ruby_llm/agents/workflows/show.html.erb +442 -0
  45. data/config/routes.rb +13 -1
  46. data/lib/generators/ruby_llm_agents/agent_generator.rb +56 -7
  47. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  48. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +110 -0
  49. data/lib/generators/ruby_llm_agents/embedder_generator.rb +107 -0
  50. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +115 -0
  51. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +108 -0
  52. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +116 -0
  53. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +178 -0
  54. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +109 -0
  55. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +103 -0
  56. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +102 -0
  57. data/lib/generators/ruby_llm_agents/install_generator.rb +76 -4
  58. data/lib/generators/ruby_llm_agents/restructure_generator.rb +292 -0
  59. data/lib/generators/ruby_llm_agents/speaker_generator.rb +121 -0
  60. data/lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt +8 -0
  61. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +99 -84
  62. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +42 -40
  63. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +26 -0
  64. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +50 -0
  65. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +26 -0
  66. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +20 -0
  67. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +38 -0
  68. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +139 -0
  69. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +21 -0
  70. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +20 -0
  71. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +20 -0
  72. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +49 -0
  73. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +53 -0
  74. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +44 -0
  75. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  76. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +41 -0
  77. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +45 -0
  78. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +35 -0
  79. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +47 -0
  80. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +50 -0
  81. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +44 -0
  82. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +38 -0
  83. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +33 -0
  84. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +228 -0
  85. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +131 -0
  86. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +255 -0
  87. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +120 -0
  88. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +102 -0
  89. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +282 -0
  90. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +228 -0
  91. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +120 -0
  92. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +110 -0
  93. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +120 -0
  94. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +212 -0
  95. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +227 -0
  96. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +251 -0
  97. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +300 -0
  98. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +56 -0
  99. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +51 -0
  100. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +107 -0
  101. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +152 -1
  102. data/lib/ruby_llm/agents/audio/speaker.rb +553 -0
  103. data/lib/ruby_llm/agents/audio/transcriber.rb +669 -0
  104. data/lib/ruby_llm/agents/base_agent.rb +675 -0
  105. data/lib/ruby_llm/agents/core/base/moderation_dsl.rb +181 -0
  106. data/lib/ruby_llm/agents/core/base/moderation_execution.rb +274 -0
  107. data/lib/ruby_llm/agents/core/base.rb +135 -0
  108. data/lib/ruby_llm/agents/core/configuration.rb +981 -0
  109. data/lib/ruby_llm/agents/core/errors.rb +150 -0
  110. data/lib/ruby_llm/agents/{instrumentation.rb → core/instrumentation.rb} +93 -4
  111. data/lib/ruby_llm/agents/core/llm_tenant.rb +358 -0
  112. data/lib/ruby_llm/agents/core/resolved_config.rb +348 -0
  113. data/lib/ruby_llm/agents/{version.rb → core/version.rb} +1 -1
  114. data/lib/ruby_llm/agents/dsl/base.rb +110 -0
  115. data/lib/ruby_llm/agents/dsl/caching.rb +142 -0
  116. data/lib/ruby_llm/agents/dsl/reliability.rb +307 -0
  117. data/lib/ruby_llm/agents/dsl.rb +41 -0
  118. data/lib/ruby_llm/agents/image/analyzer/dsl.rb +130 -0
  119. data/lib/ruby_llm/agents/image/analyzer/execution.rb +402 -0
  120. data/lib/ruby_llm/agents/image/analyzer.rb +90 -0
  121. data/lib/ruby_llm/agents/image/background_remover/dsl.rb +154 -0
  122. data/lib/ruby_llm/agents/image/background_remover/execution.rb +240 -0
  123. data/lib/ruby_llm/agents/image/background_remover.rb +89 -0
  124. data/lib/ruby_llm/agents/image/concerns/image_operation_dsl.rb +91 -0
  125. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +165 -0
  126. data/lib/ruby_llm/agents/image/editor/dsl.rb +56 -0
  127. data/lib/ruby_llm/agents/image/editor/execution.rb +207 -0
  128. data/lib/ruby_llm/agents/image/editor.rb +92 -0
  129. data/lib/ruby_llm/agents/image/generator/active_storage_support.rb +127 -0
  130. data/lib/ruby_llm/agents/image/generator/content_policy.rb +95 -0
  131. data/lib/ruby_llm/agents/image/generator/pricing.rb +353 -0
  132. data/lib/ruby_llm/agents/image/generator/templates.rb +124 -0
  133. data/lib/ruby_llm/agents/image/generator.rb +455 -0
  134. data/lib/ruby_llm/agents/image/pipeline/dsl.rb +213 -0
  135. data/lib/ruby_llm/agents/image/pipeline/execution.rb +382 -0
  136. data/lib/ruby_llm/agents/image/pipeline.rb +97 -0
  137. data/lib/ruby_llm/agents/image/transformer/dsl.rb +148 -0
  138. data/lib/ruby_llm/agents/image/transformer/execution.rb +223 -0
  139. data/lib/ruby_llm/agents/image/transformer.rb +95 -0
  140. data/lib/ruby_llm/agents/image/upscaler/dsl.rb +83 -0
  141. data/lib/ruby_llm/agents/image/upscaler/execution.rb +219 -0
  142. data/lib/ruby_llm/agents/image/upscaler.rb +81 -0
  143. data/lib/ruby_llm/agents/image/variator/dsl.rb +62 -0
  144. data/lib/ruby_llm/agents/image/variator/execution.rb +189 -0
  145. data/lib/ruby_llm/agents/image/variator.rb +80 -0
  146. data/lib/ruby_llm/agents/{alert_manager.rb → infrastructure/alert_manager.rb} +17 -22
  147. data/lib/ruby_llm/agents/infrastructure/budget/budget_query.rb +145 -0
  148. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +149 -0
  149. data/lib/ruby_llm/agents/infrastructure/budget/forecaster.rb +68 -0
  150. data/lib/ruby_llm/agents/infrastructure/budget/spend_recorder.rb +279 -0
  151. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +275 -0
  152. data/lib/ruby_llm/agents/{execution_logger_job.rb → infrastructure/execution_logger_job.rb} +17 -1
  153. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/executor.rb +2 -1
  154. data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/retry_strategy.rb +9 -3
  155. data/lib/ruby_llm/agents/{reliability.rb → infrastructure/reliability.rb} +11 -21
  156. data/lib/ruby_llm/agents/pipeline/builder.rb +215 -0
  157. data/lib/ruby_llm/agents/pipeline/context.rb +255 -0
  158. data/lib/ruby_llm/agents/pipeline/executor.rb +86 -0
  159. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +124 -0
  160. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +95 -0
  161. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +171 -0
  162. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +415 -0
  163. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +276 -0
  164. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +196 -0
  165. data/lib/ruby_llm/agents/pipeline.rb +68 -0
  166. data/lib/ruby_llm/agents/{engine.rb → rails/engine.rb} +79 -10
  167. data/lib/ruby_llm/agents/results/background_removal_result.rb +286 -0
  168. data/lib/ruby_llm/agents/{result.rb → results/base.rb} +73 -1
  169. data/lib/ruby_llm/agents/results/embedding_result.rb +243 -0
  170. data/lib/ruby_llm/agents/results/image_analysis_result.rb +314 -0
  171. data/lib/ruby_llm/agents/results/image_edit_result.rb +250 -0
  172. data/lib/ruby_llm/agents/results/image_generation_result.rb +346 -0
  173. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +399 -0
  174. data/lib/ruby_llm/agents/results/image_transform_result.rb +251 -0
  175. data/lib/ruby_llm/agents/results/image_upscale_result.rb +255 -0
  176. data/lib/ruby_llm/agents/results/image_variation_result.rb +237 -0
  177. data/lib/ruby_llm/agents/results/moderation_result.rb +158 -0
  178. data/lib/ruby_llm/agents/results/speech_result.rb +338 -0
  179. data/lib/ruby_llm/agents/results/transcription_result.rb +408 -0
  180. data/lib/ruby_llm/agents/text/embedder.rb +444 -0
  181. data/lib/ruby_llm/agents/text/moderator.rb +237 -0
  182. data/lib/ruby_llm/agents/workflow/async.rb +220 -0
  183. data/lib/ruby_llm/agents/workflow/async_executor.rb +156 -0
  184. data/lib/ruby_llm/agents/{workflow.rb → workflow/orchestrator.rb} +6 -5
  185. data/lib/ruby_llm/agents/workflow/parallel.rb +34 -17
  186. data/lib/ruby_llm/agents/workflow/thread_pool.rb +185 -0
  187. data/lib/ruby_llm/agents.rb +86 -20
  188. metadata +189 -35
  189. data/lib/ruby_llm/agents/base/caching.rb +0 -40
  190. data/lib/ruby_llm/agents/base/cost_calculation.rb +0 -105
  191. data/lib/ruby_llm/agents/base/dsl.rb +0 -324
  192. data/lib/ruby_llm/agents/base/execution.rb +0 -283
  193. data/lib/ruby_llm/agents/base/reliability_dsl.rb +0 -82
  194. data/lib/ruby_llm/agents/base/reliability_execution.rb +0 -136
  195. data/lib/ruby_llm/agents/base/response_building.rb +0 -86
  196. data/lib/ruby_llm/agents/base/tool_tracking.rb +0 -57
  197. data/lib/ruby_llm/agents/base.rb +0 -209
  198. data/lib/ruby_llm/agents/budget_tracker.rb +0 -471
  199. data/lib/ruby_llm/agents/configuration.rb +0 -357
  200. /data/lib/ruby_llm/agents/{deprecations.rb → core/deprecations.rb} +0 -0
  201. /data/lib/ruby_llm/agents/{inflections.rb → core/inflections.rb} +0 -0
  202. /data/lib/ruby_llm/agents/{attempt_tracker.rb → infrastructure/attempt_tracker.rb} +0 -0
  203. /data/lib/ruby_llm/agents/{cache_helper.rb → infrastructure/cache_helper.rb} +0 -0
  204. /data/lib/ruby_llm/agents/{circuit_breaker.rb → infrastructure/circuit_breaker.rb} +0 -0
  205. /data/lib/ruby_llm/agents/{redactor.rb → infrastructure/redactor.rb} +0 -0
  206. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/breaker_manager.rb +0 -0
  207. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/execution_constraints.rb +0 -0
  208. /data/lib/ruby_llm/agents/{reliability → infrastructure/reliability}/fallback_routing.rb +0 -0
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Async/Fiber support for concurrent agent execution
6
+ #
7
+ # Provides utilities for running agents concurrently using Ruby's Fiber scheduler.
8
+ # When used inside an `Async` block, RubyLLM automatically becomes non-blocking
9
+ # because it uses `Net::HTTP` which cooperates with Ruby's fiber scheduler.
10
+ #
11
+ # @example Basic concurrent execution
12
+ # require 'async'
13
+ #
14
+ # Async do
15
+ # results = RubyLLM::Agents::Async.batch([
16
+ # [SentimentAgent, { input: "I love this!" }],
17
+ # [SummaryAgent, { input: "Long text..." }],
18
+ # [CategoryAgent, { input: "Product review" }]
19
+ # ])
20
+ # end
21
+ #
22
+ # @example With rate limiting
23
+ # Async do
24
+ # results = RubyLLM::Agents::Async.batch(
25
+ # items.map { |item| [ProcessorAgent, { input: item }] },
26
+ # max_concurrent: 5
27
+ # )
28
+ # end
29
+ #
30
+ # @example Streaming multiple agents
31
+ # Async do
32
+ # RubyLLM::Agents::Async.each([AgentA, AgentB]) do |agent|
33
+ # agent.call(input: data) { |chunk| stream_chunk(chunk) }
34
+ # end
35
+ # end
36
+ #
37
+ # @see https://rubyllm.com/async/ RubyLLM Async Documentation
38
+ # @api public
39
+ module Async
40
+ class << self
41
+ # Executes multiple agents concurrently with optional rate limiting
42
+ #
43
+ # @param agents_with_params [Array<Array(Class, Hash)>] Array of [AgentClass, params] pairs
44
+ # @param max_concurrent [Integer, nil] Maximum concurrent executions (nil = use config default)
45
+ # @yield [result, index] Optional block called for each completed result
46
+ # @return [Array<Object>] Results in the same order as input
47
+ #
48
+ # @example Basic batch
49
+ # results = RubyLLM::Agents::Async.batch([
50
+ # [AgentA, { input: "text1" }],
51
+ # [AgentB, { input: "text2" }]
52
+ # ])
53
+ #
54
+ # @example With progress callback
55
+ # RubyLLM::Agents::Async.batch(agents_with_params) do |result, index|
56
+ # puts "Completed #{index + 1}/#{agents_with_params.size}"
57
+ # end
58
+ def batch(agents_with_params, max_concurrent: nil, &block)
59
+ ensure_async_available!
60
+
61
+ max_concurrent ||= RubyLLM::Agents.configuration.async_max_concurrency
62
+ semaphore = ::Async::Semaphore.new(max_concurrent)
63
+
64
+ Kernel.send(:Async) do
65
+ agents_with_params.each_with_index.map do |(agent_class, params), index|
66
+ Kernel.send(:Async) do
67
+ result = semaphore.acquire do
68
+ agent_class.call(**(params || {}))
69
+ end
70
+ yield(result, index) if block
71
+ result
72
+ end
73
+ end.map(&:wait)
74
+ end.wait
75
+ end
76
+
77
+ # Executes a block for each item concurrently
78
+ #
79
+ # @param items [Array] Items to process
80
+ # @param max_concurrent [Integer, nil] Maximum concurrent executions
81
+ # @yield [item] Block to execute for each item
82
+ # @return [Array<Object>] Results in the same order as input
83
+ #
84
+ # @example Process items concurrently
85
+ # RubyLLM::Agents::Async.each(texts, max_concurrent: 10) do |text|
86
+ # SummaryAgent.call(input: text)
87
+ # end
88
+ def each(items, max_concurrent: nil, &block)
89
+ ensure_async_available!
90
+ raise ArgumentError, "Block required" unless block
91
+
92
+ max_concurrent ||= RubyLLM::Agents.configuration.async_max_concurrency
93
+ semaphore = ::Async::Semaphore.new(max_concurrent)
94
+
95
+ Kernel.send(:Async) do
96
+ items.map do |item|
97
+ Kernel.send(:Async) do
98
+ semaphore.acquire do
99
+ yield(item)
100
+ end
101
+ end
102
+ end.map(&:wait)
103
+ end.wait
104
+ end
105
+
106
+ # Executes multiple agents and returns results as they complete
107
+ #
108
+ # Unlike `batch`, this yields results as soon as they're ready,
109
+ # not in order. Useful for progress updates.
110
+ #
111
+ # @param agents_with_params [Array<Array(Class, Hash)>] Array of [AgentClass, params] pairs
112
+ # @param max_concurrent [Integer, nil] Maximum concurrent executions
113
+ # @yield [result, agent_class, index] Block called as each result completes
114
+ # @return [Hash<Integer, Object>] Results keyed by original index
115
+ #
116
+ # @example Stream results as they complete
117
+ # RubyLLM::Agents::Async.stream(agents) do |result, agent_class, index|
118
+ # puts "#{agent_class.name} finished: #{result.content}"
119
+ # end
120
+ def stream(agents_with_params, max_concurrent: nil, &block)
121
+ ensure_async_available!
122
+
123
+ max_concurrent ||= RubyLLM::Agents.configuration.async_max_concurrency
124
+ semaphore = ::Async::Semaphore.new(max_concurrent)
125
+ results = {}
126
+ mutex = Mutex.new
127
+
128
+ Kernel.send(:Async) do |task|
129
+ agents_with_params.each_with_index.map do |(agent_class, params), index|
130
+ Kernel.send(:Async) do
131
+ result = semaphore.acquire do
132
+ agent_class.call(**(params || {}))
133
+ end
134
+
135
+ mutex.synchronize { results[index] = result }
136
+ yield(result, agent_class, index) if block
137
+ end
138
+ end.map(&:wait)
139
+ end.wait
140
+
141
+ results
142
+ end
143
+
144
+ # Wraps a synchronous agent call in an async task
145
+ #
146
+ # @param agent_class [Class] The agent class to call
147
+ # @param params [Hash] Parameters to pass to the agent
148
+ # @yield [chunk] Optional streaming block
149
+ # @return [Async::Task] The async task (call .wait to get result)
150
+ #
151
+ # @example Fire and forget
152
+ # task = RubyLLM::Agents::Async.call_async(MyAgent, input: "Hello")
153
+ # # ... do other work ...
154
+ # result = task.wait
155
+ def call_async(agent_class, **params, &block)
156
+ ensure_async_available!
157
+
158
+ Kernel.send(:Async) do
159
+ agent_class.call(**params, &block)
160
+ end
161
+ end
162
+
163
+ # Sleeps without blocking other fibers
164
+ #
165
+ # Automatically uses async sleep when in async context,
166
+ # falls back to regular sleep otherwise.
167
+ #
168
+ # @param seconds [Numeric] Duration to sleep
169
+ # @return [void]
170
+ def sleep(seconds)
171
+ if async_context?
172
+ ::Async::Task.current.sleep(seconds)
173
+ else
174
+ Kernel.sleep(seconds)
175
+ end
176
+ end
177
+
178
+ # Checks if async gem is available
179
+ #
180
+ # @return [Boolean] true if async gem is loaded
181
+ def available?
182
+ RubyLLM::Agents.configuration.async_available?
183
+ end
184
+
185
+ # Checks if currently in an async context
186
+ #
187
+ # @return [Boolean] true if inside an Async block
188
+ def async_context?
189
+ RubyLLM::Agents.configuration.async_context?
190
+ end
191
+
192
+ private
193
+
194
+ # Raises an error if async gem is not available
195
+ #
196
+ # @raise [RuntimeError] If async gem is not loaded
197
+ def ensure_async_available!
198
+ return if available?
199
+
200
+ raise <<~ERROR
201
+ Async gem is required for concurrent agent execution.
202
+
203
+ Add to your Gemfile:
204
+ gem 'async'
205
+
206
+ Then:
207
+ bundle install
208
+
209
+ Usage:
210
+ require 'async'
211
+
212
+ Async do
213
+ RubyLLM::Agents::Async.batch([...])
214
+ end
215
+ ERROR
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Fiber-based concurrent executor for parallel workflows
7
+ #
8
+ # Provides an alternative to ThreadPool that uses Ruby's Fiber scheduler
9
+ # for lightweight concurrency. Automatically used when the async gem is
10
+ # available and we're inside an async context.
11
+ #
12
+ # @example Basic usage
13
+ # executor = AsyncExecutor.new(max_concurrent: 4)
14
+ # executor.post { perform_task_1 }
15
+ # executor.post { perform_task_2 }
16
+ # executor.wait_for_completion
17
+ #
18
+ # @example With fail-fast
19
+ # executor = AsyncExecutor.new(max_concurrent: 4)
20
+ # executor.post { risky_task }
21
+ # executor.abort! if something_failed
22
+ # executor.wait_for_completion
23
+ #
24
+ # @api private
25
+ class AsyncExecutor
26
+ attr_reader :max_concurrent
27
+
28
+ # Creates a new async executor
29
+ #
30
+ # @param max_concurrent [Integer] Maximum concurrent fibers (default: 10)
31
+ def initialize(max_concurrent: 10)
32
+ @max_concurrent = max_concurrent
33
+ @tasks = []
34
+ @results = []
35
+ @mutex = Mutex.new
36
+ @aborted = false
37
+ @semaphore = nil
38
+ end
39
+
40
+ # Submits a task for execution
41
+ #
42
+ # @yield Block to execute
43
+ # @return [void]
44
+ def post(&block)
45
+ @mutex.synchronize do
46
+ @tasks << block
47
+ end
48
+ end
49
+
50
+ # Signals that remaining tasks should be skipped
51
+ #
52
+ # Currently running tasks will complete, but pending tasks will be skipped.
53
+ #
54
+ # @return [void]
55
+ def abort!
56
+ @mutex.synchronize do
57
+ @aborted = true
58
+ end
59
+ end
60
+
61
+ # Returns whether the executor has been aborted
62
+ #
63
+ # @return [Boolean] true if abort! was called
64
+ def aborted?
65
+ @mutex.synchronize { @aborted }
66
+ end
67
+
68
+ # Executes all submitted tasks and waits for completion
69
+ #
70
+ # @param timeout [Integer, nil] Maximum seconds to wait (nil = indefinite)
71
+ # @return [Boolean] true if all tasks completed, false if timeout
72
+ def wait_for_completion(timeout: nil)
73
+ return true if @tasks.empty?
74
+
75
+ ensure_async_available!
76
+
77
+ @semaphore = ::Async::Semaphore.new(@max_concurrent)
78
+
79
+ if timeout
80
+ execute_with_timeout(timeout)
81
+ else
82
+ execute_all
83
+ true
84
+ end
85
+ end
86
+
87
+ # Shuts down the executor
88
+ #
89
+ # For AsyncExecutor this is a no-op since fibers are garbage collected.
90
+ #
91
+ # @param timeout [Integer] Ignored for async executor
92
+ # @return [void]
93
+ def shutdown(timeout: 5)
94
+ # No-op for fiber-based executor
95
+ # Fibers are lightweight and garbage collected
96
+ end
97
+
98
+ # Waits for termination (compatibility with ThreadPool)
99
+ #
100
+ # @param timeout [Integer] Ignored for async executor
101
+ # @return [void]
102
+ def wait_for_termination(timeout: 5)
103
+ # No-op for fiber-based executor
104
+ end
105
+
106
+ private
107
+
108
+ # Executes all tasks with async
109
+ #
110
+ # @return [void]
111
+ def execute_all
112
+ Kernel.send(:Async) do
113
+ @tasks.map do |task|
114
+ Kernel.send(:Async) do
115
+ next if aborted?
116
+
117
+ @semaphore.acquire do
118
+ next if aborted?
119
+ task.call
120
+ end
121
+ end
122
+ end.map(&:wait)
123
+ end.wait
124
+ end
125
+
126
+ # Executes all tasks with a timeout
127
+ #
128
+ # @param timeout [Integer] Maximum seconds to wait
129
+ # @return [Boolean] true if completed, false if timeout
130
+ def execute_with_timeout(timeout)
131
+ completed = false
132
+
133
+ Kernel.send(:Async) do |task|
134
+ task.with_timeout(timeout) do
135
+ execute_all
136
+ completed = true
137
+ rescue ::Async::TimeoutError
138
+ completed = false
139
+ end
140
+ end.wait
141
+
142
+ completed
143
+ end
144
+
145
+ # Ensures async gem is available
146
+ #
147
+ # @raise [RuntimeError] If async gem is not loaded
148
+ def ensure_async_available!
149
+ return if defined?(::Async) && defined?(::Async::Semaphore)
150
+
151
+ raise "AsyncExecutor requires the 'async' gem. Add gem 'async' to your Gemfile."
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -1,10 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "workflow/result"
4
- require_relative "workflow/instrumentation"
5
- require_relative "workflow/pipeline"
6
- require_relative "workflow/parallel"
7
- require_relative "workflow/router"
3
+ require_relative "result"
4
+ require_relative "instrumentation"
5
+ require_relative "thread_pool"
6
+ require_relative "pipeline"
7
+ require_relative "parallel"
8
+ require_relative "router"
8
9
 
9
10
  module RubyLLM
10
11
  module Agents
@@ -149,17 +149,22 @@ module RubyLLM
149
149
  results = {}
150
150
  errors = {}
151
151
  mutex = Mutex.new
152
- should_abort = false
153
152
 
154
- # Create thread pool based on concurrency setting
155
- threads = self.class.branches.map do |name, config|
156
- Thread.new do
153
+ # Determine pool size based on concurrency setting
154
+ pool_size = self.class.concurrency || self.class.branches.size
155
+
156
+ # Use async executor when in async context, otherwise use thread pool
157
+ pool = create_executor(pool_size)
158
+
159
+ # Submit all branches to the pool
160
+ self.class.branches.each do |name, config|
161
+ pool.post do
157
162
  Thread.current.name = "parallel-#{name}"
158
163
  Thread.current[:branch_name] = name
159
164
 
160
165
  begin
161
- # Check if we should abort early
162
- if self.class.fail_fast? && should_abort
166
+ # Check if pool was aborted (fail-fast triggered)
167
+ if pool.aborted?
163
168
  mutex.synchronize { results[name] = nil }
164
169
  next
165
170
  end
@@ -178,29 +183,24 @@ module RubyLLM
178
183
  mutex.synchronize do
179
184
  results[name] = result
180
185
 
181
- # Check for failure
186
+ # Check for failure and trigger fail-fast if needed
182
187
  if result.respond_to?(:error?) && result.error? && !config[:optional]
183
- should_abort = true if self.class.fail_fast?
188
+ pool.abort! if self.class.fail_fast?
184
189
  end
185
190
  end
186
191
  rescue StandardError => e
187
192
  mutex.synchronize do
188
193
  errors[name] = e
189
194
  results[name] = nil
190
- should_abort = true if self.class.fail_fast? && !config[:optional]
195
+ pool.abort! if self.class.fail_fast? && !config[:optional]
191
196
  end
192
197
  end
193
198
  end
194
199
  end
195
200
 
196
- # Apply concurrency limit if set
197
- if self.class.concurrency
198
- threads.each_slice(self.class.concurrency) do |thread_batch|
199
- thread_batch.each(&:join)
200
- end
201
- else
202
- threads.each(&:join)
203
- end
201
+ # Wait for all branches to complete
202
+ pool.wait_for_completion
203
+ pool.shutdown
204
204
 
205
205
  # Determine overall status
206
206
  status = determine_parallel_status(results, errors)
@@ -276,6 +276,23 @@ module RubyLLM
276
276
  duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
277
277
  )
278
278
  end
279
+
280
+ # Creates the appropriate executor based on context
281
+ #
282
+ # Uses AsyncExecutor when in async context for fiber-based concurrency,
283
+ # otherwise falls back to ThreadPool for traditional thread-based execution.
284
+ #
285
+ # @param size [Integer] Number of concurrent workers/fibers
286
+ # @return [ThreadPool, AsyncExecutor] The executor instance
287
+ def create_executor(size)
288
+ config = RubyLLM::Agents.configuration
289
+
290
+ if config.async_context?
291
+ AsyncExecutor.new(max_concurrent: size)
292
+ else
293
+ ThreadPool.new(size: size)
294
+ end
295
+ end
279
296
  end
280
297
  end
281
298
  end
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Simple bounded thread pool for parallel workflow execution
7
+ #
8
+ # Provides a fixed-size pool of worker threads that process submitted tasks.
9
+ # Supports fail-fast abort and graceful shutdown.
10
+ #
11
+ # @example Basic usage
12
+ # pool = ThreadPool.new(size: 4)
13
+ # pool.post { perform_task_1 }
14
+ # pool.post { perform_task_2 }
15
+ # pool.wait_for_completion
16
+ # pool.shutdown
17
+ #
18
+ # @example With fail-fast
19
+ # pool = ThreadPool.new(size: 4)
20
+ # begin
21
+ # pool.post { risky_task }
22
+ # rescue => e
23
+ # pool.abort! # Signal workers to stop
24
+ # end
25
+ # pool.shutdown
26
+ #
27
+ # @api private
28
+ class ThreadPool
29
+ attr_reader :size
30
+
31
+ # Creates a new thread pool
32
+ #
33
+ # @param size [Integer] Number of worker threads (default: 4)
34
+ def initialize(size: 4)
35
+ @size = size
36
+ @queue = Queue.new
37
+ @workers = []
38
+ @mutex = Mutex.new
39
+ @completion_condition = ConditionVariable.new
40
+ @pending_count = 0
41
+ @completed_count = 0
42
+ @aborted = false
43
+ @shutdown = false
44
+
45
+ spawn_workers
46
+ end
47
+
48
+ # Submits a task to the pool
49
+ #
50
+ # @yield Block to execute in a worker thread
51
+ # @return [void]
52
+ # @raise [RuntimeError] If pool has been shutdown
53
+ def post(&block)
54
+ raise "ThreadPool has been shutdown" if @shutdown
55
+
56
+ @mutex.synchronize do
57
+ @pending_count += 1
58
+ end
59
+
60
+ @queue.push(block)
61
+ end
62
+
63
+ # Signals workers to abort remaining tasks
64
+ #
65
+ # Currently running tasks will complete, but pending tasks will be skipped.
66
+ #
67
+ # @return [void]
68
+ def abort!
69
+ @mutex.synchronize do
70
+ @aborted = true
71
+ end
72
+ end
73
+
74
+ # Returns whether the pool has been aborted
75
+ #
76
+ # @return [Boolean] true if abort! was called
77
+ def aborted?
78
+ @mutex.synchronize { @aborted }
79
+ end
80
+
81
+ # Waits for all submitted tasks to complete
82
+ #
83
+ # @param timeout [Integer, nil] Maximum seconds to wait (nil = indefinite)
84
+ # @return [Boolean] true if all tasks completed, false if timeout
85
+ def wait_for_completion(timeout: nil)
86
+ deadline = timeout ? Time.current + timeout : nil
87
+
88
+ @mutex.synchronize do
89
+ loop do
90
+ return true if @pending_count == @completed_count
91
+
92
+ if deadline
93
+ remaining = deadline - Time.current
94
+ return false if remaining <= 0
95
+
96
+ @completion_condition.wait(@mutex, remaining)
97
+ else
98
+ @completion_condition.wait(@mutex)
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ # Shuts down the pool and waits for workers to terminate
105
+ #
106
+ # @param timeout [Integer] Maximum seconds to wait for termination
107
+ # @return [void]
108
+ def shutdown(timeout: 5)
109
+ @shutdown = true
110
+
111
+ # Send poison pills to stop workers
112
+ @size.times { @queue.push(nil) }
113
+
114
+ wait_for_termination(timeout: timeout)
115
+ end
116
+
117
+ # Waits for all worker threads to terminate
118
+ #
119
+ # @param timeout [Integer] Maximum seconds to wait
120
+ # @return [void]
121
+ def wait_for_termination(timeout: 5)
122
+ deadline = Time.current + timeout
123
+
124
+ @workers.each do |worker|
125
+ remaining = deadline - Time.current
126
+ break if remaining <= 0
127
+
128
+ worker.join(remaining)
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Spawns the worker threads
135
+ #
136
+ # @return [void]
137
+ def spawn_workers
138
+ @size.times do |i|
139
+ @workers << Thread.new do
140
+ Thread.current.name = "pool-worker-#{i}"
141
+ worker_loop
142
+ end
143
+ end
144
+ end
145
+
146
+ # Main worker loop - processes tasks from the queue
147
+ #
148
+ # @return [void]
149
+ def worker_loop
150
+ loop do
151
+ task = @queue.pop
152
+
153
+ # nil is the poison pill - time to exit
154
+ break if task.nil?
155
+
156
+ # Skip if aborted
157
+ if aborted?
158
+ mark_completed
159
+ next
160
+ end
161
+
162
+ begin
163
+ task.call
164
+ rescue StandardError
165
+ # Errors are handled by the task itself (via rescue in the block)
166
+ # We just need to ensure we mark completion
167
+ ensure
168
+ mark_completed
169
+ end
170
+ end
171
+ end
172
+
173
+ # Marks a task as completed and signals waiters
174
+ #
175
+ # @return [void]
176
+ def mark_completed
177
+ @mutex.synchronize do
178
+ @completed_count += 1
179
+ @completion_condition.broadcast
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end