ruby_llm-agents 1.0.0 → 1.2.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 +4 -4
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
- data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
- data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
- data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
- data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
- data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
- data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
- data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
- data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
- data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
- data/app/models/ruby_llm/agents/execution.rb +50 -14
- data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
- data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
- data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
- data/app/models/ruby_llm/agents/tenant.rb +146 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
- data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
- data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
- data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
- data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
- data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
- data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
- data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
- data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
- data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
- data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
- data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
- data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
- data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
- data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
- data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
- data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
- data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
- data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
- data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
- data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
- data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
- data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
- data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
- data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
- data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
- data/config/routes.rb +1 -1
- data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
- data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
- data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
- data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
- data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
- data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
- data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
- data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
- data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
- data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
- data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
- data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
- data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
- data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
- data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
- data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
- data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
- data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
- data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
- data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
- data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
- data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
- data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
- data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
- data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
- data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
- data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
- data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
- data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
- data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
- data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
- data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
- data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
- data/lib/ruby_llm/agents/core/configuration.rb +55 -43
- data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
- data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
- data/lib/ruby_llm/agents/pipeline.rb +69 -0
- data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
- data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
- data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
- data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
- data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
- data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
- data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
- data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
- data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
- data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
- data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
- data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
- data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
- data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
- data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
- data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
- data/lib/ruby_llm/agents/workflow/result.rb +202 -0
- data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
- data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
- metadata +43 -6
- data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
- data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
- data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
- data/lib/ruby_llm/agents/workflow/router.rb +0 -429
|
@@ -3,32 +3,62 @@
|
|
|
3
3
|
require_relative "result"
|
|
4
4
|
require_relative "instrumentation"
|
|
5
5
|
require_relative "thread_pool"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
8
|
-
require_relative "router"
|
|
6
|
+
require_relative "dsl"
|
|
7
|
+
require_relative "dsl/executor"
|
|
9
8
|
|
|
10
9
|
module RubyLLM
|
|
11
10
|
module Agents
|
|
12
11
|
# Base class for workflow orchestration
|
|
13
12
|
#
|
|
14
13
|
# Provides shared functionality for composing multiple agents into
|
|
15
|
-
# coordinated workflows
|
|
16
|
-
# -
|
|
17
|
-
# - Parallel
|
|
18
|
-
# -
|
|
14
|
+
# coordinated workflows using the DSL:
|
|
15
|
+
# - Sequential steps with data flowing between them
|
|
16
|
+
# - Parallel execution with result aggregation
|
|
17
|
+
# - Conditional routing based on step results
|
|
19
18
|
#
|
|
20
|
-
# @example
|
|
21
|
-
# class
|
|
22
|
-
#
|
|
23
|
-
#
|
|
19
|
+
# @example Minimal workflow
|
|
20
|
+
# class SimpleWorkflow < RubyLLM::Agents::Workflow
|
|
21
|
+
# step :fetch, FetcherAgent
|
|
22
|
+
# step :process, ProcessorAgent
|
|
23
|
+
# step :save, SaverAgent
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Full-featured workflow
|
|
27
|
+
# class OrderWorkflow < RubyLLM::Agents::Workflow
|
|
28
|
+
# description "Process customer orders end-to-end"
|
|
29
|
+
#
|
|
30
|
+
# input do
|
|
31
|
+
# required :order_id, String
|
|
32
|
+
# optional :priority, String, default: "normal"
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# step :fetch, FetcherAgent, timeout: 1.minute
|
|
36
|
+
# step :validate, ValidatorAgent
|
|
37
|
+
#
|
|
38
|
+
# step :process, on: -> { validate.tier } do |route|
|
|
39
|
+
# route.premium PremiumAgent
|
|
40
|
+
# route.standard StandardAgent
|
|
41
|
+
# route.default DefaultAgent
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# parallel do
|
|
45
|
+
# step :analyze, AnalyzerAgent
|
|
46
|
+
# step :summarize, SummarizerAgent
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# step :notify, NotifierAgent, if: :should_notify?
|
|
50
|
+
#
|
|
51
|
+
# private
|
|
52
|
+
#
|
|
53
|
+
# def should_notify?
|
|
54
|
+
# input.callback_url.present?
|
|
55
|
+
# end
|
|
24
56
|
# end
|
|
25
57
|
#
|
|
26
|
-
# @see RubyLLM::Agents::Workflow::Pipeline
|
|
27
|
-
# @see RubyLLM::Agents::Workflow::Parallel
|
|
28
|
-
# @see RubyLLM::Agents::Workflow::Router
|
|
29
58
|
# @api public
|
|
30
59
|
class Workflow
|
|
31
60
|
include Workflow::Instrumentation
|
|
61
|
+
include Workflow::DSL
|
|
32
62
|
|
|
33
63
|
class << self
|
|
34
64
|
# @!attribute [rw] version
|
|
@@ -47,6 +77,10 @@ module RubyLLM
|
|
|
47
77
|
# @return [String, nil] Description of the workflow
|
|
48
78
|
attr_accessor :_description
|
|
49
79
|
|
|
80
|
+
# @!attribute [rw] max_recursion_depth
|
|
81
|
+
# @return [Integer] Maximum recursion depth for self-referential workflows
|
|
82
|
+
attr_accessor :_max_recursion_depth
|
|
83
|
+
|
|
50
84
|
# Sets or returns the workflow version
|
|
51
85
|
#
|
|
52
86
|
# @param value [String, nil] Version string to set
|
|
@@ -95,13 +129,34 @@ module RubyLLM
|
|
|
95
129
|
end
|
|
96
130
|
end
|
|
97
131
|
|
|
132
|
+
# Sets or returns the maximum recursion depth
|
|
133
|
+
#
|
|
134
|
+
# @param value [Integer, nil] Max depth to set
|
|
135
|
+
# @return [Integer] The current max recursion depth (default: 10)
|
|
136
|
+
def max_recursion_depth(value = nil)
|
|
137
|
+
if value
|
|
138
|
+
self._max_recursion_depth = value.to_i
|
|
139
|
+
else
|
|
140
|
+
_max_recursion_depth || 10
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
98
144
|
# Factory method to instantiate and execute a workflow
|
|
99
145
|
#
|
|
146
|
+
# Supports both hash and keyword argument styles:
|
|
147
|
+
# MyWorkflow.call(order_id: "123")
|
|
148
|
+
# MyWorkflow.call({ order_id: "123" })
|
|
149
|
+
#
|
|
150
|
+
# @param input [Hash] Input hash (optional)
|
|
100
151
|
# @param kwargs [Hash] Parameters to pass to the workflow
|
|
101
152
|
# @yield [chunk] Optional block for streaming support
|
|
102
153
|
# @return [WorkflowResult] The workflow result with aggregate metrics
|
|
103
|
-
def call(**kwargs, &block)
|
|
104
|
-
|
|
154
|
+
def call(input = nil, **kwargs, &block)
|
|
155
|
+
# Support both call(hash) and call(**kwargs) patterns
|
|
156
|
+
merged_input = input.is_a?(Hash) ? input.merge(kwargs) : kwargs
|
|
157
|
+
# Pass input to constructor to maintain backward compatibility with
|
|
158
|
+
# legacy subclasses that override call without arguments
|
|
159
|
+
new(**merged_input).call(&block)
|
|
105
160
|
end
|
|
106
161
|
end
|
|
107
162
|
|
|
@@ -117,6 +172,14 @@ module RubyLLM
|
|
|
117
172
|
# @return [Integer, nil] The ID of the root execution record
|
|
118
173
|
attr_reader :execution_id
|
|
119
174
|
|
|
175
|
+
# @!attribute [r] step_results
|
|
176
|
+
# @return [Hash<Symbol, Result>] Results from executed steps
|
|
177
|
+
attr_reader :step_results
|
|
178
|
+
|
|
179
|
+
# @!attribute [r] recursion_depth
|
|
180
|
+
# @return [Integer] Current recursion depth for self-referential workflows
|
|
181
|
+
attr_reader :recursion_depth
|
|
182
|
+
|
|
120
183
|
# Creates a new workflow instance
|
|
121
184
|
#
|
|
122
185
|
# @param kwargs [Hash] Parameters for the workflow
|
|
@@ -126,17 +189,94 @@ module RubyLLM
|
|
|
126
189
|
@execution_id = nil
|
|
127
190
|
@accumulated_cost = 0.0
|
|
128
191
|
@step_results = {}
|
|
192
|
+
@validated_input = nil
|
|
193
|
+
|
|
194
|
+
# Extract recursion context from execution_metadata
|
|
195
|
+
metadata = kwargs[:execution_metadata] || {}
|
|
196
|
+
@recursion_depth = metadata[:recursion_depth] || 0
|
|
197
|
+
@remaining_timeout = metadata[:remaining_timeout]
|
|
198
|
+
@remaining_cost_budget = metadata[:remaining_cost_budget]
|
|
199
|
+
|
|
200
|
+
# Check recursion depth
|
|
201
|
+
check_recursion_depth!
|
|
129
202
|
end
|
|
130
203
|
|
|
131
204
|
# Executes the workflow
|
|
132
205
|
#
|
|
133
|
-
#
|
|
206
|
+
# When using the new DSL with `step` declarations, this method
|
|
207
|
+
# automatically executes the workflow using the DSL executor.
|
|
208
|
+
# For legacy subclasses (Pipeline, Parallel, Router), this raises
|
|
209
|
+
# NotImplementedError to be overridden.
|
|
210
|
+
#
|
|
211
|
+
# Supports both hash and keyword argument styles:
|
|
212
|
+
# workflow.call(order_id: "123")
|
|
213
|
+
# workflow.call({ order_id: "123" })
|
|
214
|
+
#
|
|
215
|
+
# @param input [Hash] Input hash (optional)
|
|
216
|
+
# @param kwargs [Hash] Keyword arguments for input
|
|
134
217
|
# @yield [chunk] Optional block for streaming support
|
|
135
218
|
# @return [WorkflowResult] The workflow result
|
|
136
|
-
def call(&block)
|
|
137
|
-
|
|
219
|
+
def call(input = nil, **kwargs, &block)
|
|
220
|
+
# Merge input sources: constructor options, hash arg, keyword args
|
|
221
|
+
merged_input = @options.merge(input.is_a?(Hash) ? input : {}).merge(kwargs)
|
|
222
|
+
@options = merged_input
|
|
223
|
+
|
|
224
|
+
# Use DSL executor if steps are defined with the new DSL
|
|
225
|
+
if self.class.step_configs.any?
|
|
226
|
+
instrument_workflow do
|
|
227
|
+
execute_with_dsl(&block)
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
raise NotImplementedError, "#{self.class} must implement #call or define steps"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Validates workflow input and executes a dry run
|
|
235
|
+
#
|
|
236
|
+
# Returns information about the workflow without executing agents.
|
|
237
|
+
# Supports both positional hash and keyword arguments.
|
|
238
|
+
#
|
|
239
|
+
# @param input_hash [Hash] Input hash (optional)
|
|
240
|
+
# @param input [Hash] Keyword arguments for input
|
|
241
|
+
# @return [Hash] Validation results and workflow structure
|
|
242
|
+
def self.dry_run(input_hash = nil, **input)
|
|
243
|
+
input = input_hash.merge(input) if input_hash.is_a?(Hash)
|
|
244
|
+
errors = []
|
|
245
|
+
|
|
246
|
+
# Validate input if schema defined
|
|
247
|
+
if input_schema
|
|
248
|
+
begin
|
|
249
|
+
input_schema.validate!(input)
|
|
250
|
+
rescue DSL::InputSchema::ValidationError => e
|
|
251
|
+
errors.concat(e.errors)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Validate configuration
|
|
256
|
+
errors.concat(validate_configuration)
|
|
257
|
+
|
|
258
|
+
{
|
|
259
|
+
valid: errors.empty?,
|
|
260
|
+
input_errors: errors,
|
|
261
|
+
steps: step_metadata.map { |s| s[:name] },
|
|
262
|
+
agents: step_metadata.map { |s| s[:agent] }.compact,
|
|
263
|
+
parallel_groups: parallel_groups.map(&:to_h),
|
|
264
|
+
warnings: validate_configuration
|
|
265
|
+
}
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
private
|
|
269
|
+
|
|
270
|
+
# Executes the workflow using the DSL executor
|
|
271
|
+
#
|
|
272
|
+
# @return [WorkflowResult] The workflow result
|
|
273
|
+
def execute_with_dsl(&block)
|
|
274
|
+
executor = DSL::Executor.new(self)
|
|
275
|
+
executor.execute(&block)
|
|
138
276
|
end
|
|
139
277
|
|
|
278
|
+
public
|
|
279
|
+
|
|
140
280
|
protected
|
|
141
281
|
|
|
142
282
|
# Executes a single agent within the workflow context
|
|
@@ -191,13 +331,29 @@ module RubyLLM
|
|
|
191
331
|
#
|
|
192
332
|
# @raise [WorkflowCostExceededError] If cost exceeds max_cost
|
|
193
333
|
def check_cost_threshold!
|
|
194
|
-
|
|
195
|
-
|
|
334
|
+
# Check against remaining budget if we're in a sub-workflow
|
|
335
|
+
effective_max = @remaining_cost_budget || self.class.max_cost
|
|
336
|
+
return unless effective_max
|
|
337
|
+
return if @accumulated_cost <= effective_max
|
|
196
338
|
|
|
197
339
|
raise WorkflowCostExceededError.new(
|
|
198
|
-
"Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{
|
|
340
|
+
"Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{effective_max})",
|
|
199
341
|
accumulated_cost: @accumulated_cost,
|
|
200
|
-
max_cost:
|
|
342
|
+
max_cost: effective_max
|
|
343
|
+
)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Checks if recursion depth exceeds the maximum
|
|
347
|
+
#
|
|
348
|
+
# @raise [RecursionDepthExceededError] If depth exceeds max
|
|
349
|
+
def check_recursion_depth!
|
|
350
|
+
max_depth = self.class.max_recursion_depth
|
|
351
|
+
return if @recursion_depth <= max_depth
|
|
352
|
+
|
|
353
|
+
raise RecursionDepthExceededError.new(
|
|
354
|
+
"Workflow recursion depth (#{@recursion_depth}) exceeded maximum (#{max_depth})",
|
|
355
|
+
current_depth: @recursion_depth,
|
|
356
|
+
max_depth: max_depth
|
|
201
357
|
)
|
|
202
358
|
end
|
|
203
359
|
|
|
@@ -245,5 +401,16 @@ module RubyLLM
|
|
|
245
401
|
@max_cost = max_cost
|
|
246
402
|
end
|
|
247
403
|
end
|
|
404
|
+
|
|
405
|
+
# Error raised when workflow recursion depth exceeds the maximum
|
|
406
|
+
class RecursionDepthExceededError < StandardError
|
|
407
|
+
attr_reader :current_depth, :max_depth
|
|
408
|
+
|
|
409
|
+
def initialize(message, current_depth:, max_depth:)
|
|
410
|
+
super(message)
|
|
411
|
+
@current_depth = current_depth
|
|
412
|
+
@max_depth = max_depth
|
|
413
|
+
end
|
|
414
|
+
end
|
|
248
415
|
end
|
|
249
416
|
end
|
|
@@ -385,6 +385,208 @@ module RubyLLM
|
|
|
385
385
|
{ skipped: true, step_name: step_name, reason: reason }
|
|
386
386
|
end
|
|
387
387
|
end
|
|
388
|
+
|
|
389
|
+
# Result wrapper for sub-workflow execution
|
|
390
|
+
#
|
|
391
|
+
# Wraps a nested workflow result while providing access to
|
|
392
|
+
# aggregate metrics and the underlying workflow result.
|
|
393
|
+
#
|
|
394
|
+
# @api public
|
|
395
|
+
class SubWorkflowResult
|
|
396
|
+
attr_reader :content, :sub_workflow_result, :workflow_type, :step_name
|
|
397
|
+
|
|
398
|
+
def initialize(content:, sub_workflow_result:, workflow_type:, step_name:)
|
|
399
|
+
@content = content
|
|
400
|
+
@sub_workflow_result = sub_workflow_result
|
|
401
|
+
@workflow_type = workflow_type
|
|
402
|
+
@step_name = step_name
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def success?
|
|
406
|
+
sub_workflow_result.respond_to?(:success?) ? sub_workflow_result.success? : true
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def error?
|
|
410
|
+
sub_workflow_result.respond_to?(:error?) ? sub_workflow_result.error? : false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def skipped?
|
|
414
|
+
false
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Delegate metrics to sub-workflow result
|
|
418
|
+
def input_tokens
|
|
419
|
+
sub_workflow_result.respond_to?(:input_tokens) ? sub_workflow_result.input_tokens : 0
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def output_tokens
|
|
423
|
+
sub_workflow_result.respond_to?(:output_tokens) ? sub_workflow_result.output_tokens : 0
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def total_tokens
|
|
427
|
+
input_tokens + output_tokens
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def cached_tokens
|
|
431
|
+
sub_workflow_result.respond_to?(:cached_tokens) ? sub_workflow_result.cached_tokens : 0
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def input_cost
|
|
435
|
+
sub_workflow_result.respond_to?(:input_cost) ? sub_workflow_result.input_cost : 0.0
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def output_cost
|
|
439
|
+
sub_workflow_result.respond_to?(:output_cost) ? sub_workflow_result.output_cost : 0.0
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def total_cost
|
|
443
|
+
sub_workflow_result.respond_to?(:total_cost) ? sub_workflow_result.total_cost : 0.0
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Access sub-workflow steps
|
|
447
|
+
def steps
|
|
448
|
+
sub_workflow_result.respond_to?(:steps) ? sub_workflow_result.steps : {}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def to_h
|
|
452
|
+
{
|
|
453
|
+
content: content,
|
|
454
|
+
workflow_type: workflow_type,
|
|
455
|
+
step_name: step_name,
|
|
456
|
+
sub_workflow: sub_workflow_result.respond_to?(:to_h) ? sub_workflow_result.to_h : sub_workflow_result,
|
|
457
|
+
input_tokens: input_tokens,
|
|
458
|
+
output_tokens: output_tokens,
|
|
459
|
+
total_cost: total_cost
|
|
460
|
+
}
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Delegate hash access to content
|
|
464
|
+
def [](key)
|
|
465
|
+
content.is_a?(Hash) ? content[key] : nil
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def dig(*keys)
|
|
469
|
+
content.is_a?(Hash) ? content.dig(*keys) : nil
|
|
470
|
+
end
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# Result wrapper for iteration execution
|
|
474
|
+
#
|
|
475
|
+
# Tracks results for each item in an iteration with
|
|
476
|
+
# aggregate success/failure counts and metrics.
|
|
477
|
+
#
|
|
478
|
+
# @api public
|
|
479
|
+
class IterationResult
|
|
480
|
+
attr_reader :step_name, :item_results, :errors
|
|
481
|
+
|
|
482
|
+
def initialize(step_name:, item_results: [], errors: {})
|
|
483
|
+
@step_name = step_name
|
|
484
|
+
@item_results = item_results
|
|
485
|
+
@errors = errors
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def content
|
|
489
|
+
item_results.map do |result|
|
|
490
|
+
result.respond_to?(:content) ? result.content : result
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def success?
|
|
495
|
+
errors.empty? && item_results.all? do |r|
|
|
496
|
+
!r.respond_to?(:error?) || !r.error?
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def error?
|
|
501
|
+
!success?
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def partial?
|
|
505
|
+
errors.any? && item_results.any? do |r|
|
|
506
|
+
!r.respond_to?(:error?) || !r.error?
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def skipped?
|
|
511
|
+
false
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def successful_count
|
|
515
|
+
item_results.count { |r| !r.respond_to?(:error?) || !r.error? }
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
def failed_count
|
|
519
|
+
errors.size + item_results.count { |r| r.respond_to?(:error?) && r.error? }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def total_count
|
|
523
|
+
item_results.size + errors.size
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
# Aggregate metrics across all items
|
|
527
|
+
def input_tokens
|
|
528
|
+
item_results.sum { |r| r.respond_to?(:input_tokens) ? r.input_tokens : 0 }
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def output_tokens
|
|
532
|
+
item_results.sum { |r| r.respond_to?(:output_tokens) ? r.output_tokens : 0 }
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def total_tokens
|
|
536
|
+
input_tokens + output_tokens
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def cached_tokens
|
|
540
|
+
item_results.sum { |r| r.respond_to?(:cached_tokens) ? r.cached_tokens : 0 }
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def input_cost
|
|
544
|
+
item_results.sum { |r| r.respond_to?(:input_cost) ? r.input_cost : 0.0 }
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def output_cost
|
|
548
|
+
item_results.sum { |r| r.respond_to?(:output_cost) ? r.output_cost : 0.0 }
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def total_cost
|
|
552
|
+
item_results.sum { |r| r.respond_to?(:total_cost) ? r.total_cost : 0.0 }
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
def to_h
|
|
556
|
+
{
|
|
557
|
+
step_name: step_name,
|
|
558
|
+
total_count: total_count,
|
|
559
|
+
successful_count: successful_count,
|
|
560
|
+
failed_count: failed_count,
|
|
561
|
+
success: success?,
|
|
562
|
+
items: item_results.map { |r| r.respond_to?(:to_h) ? r.to_h : r },
|
|
563
|
+
errors: errors.transform_values { |e| { class: e.class.name, message: e.message } },
|
|
564
|
+
input_tokens: input_tokens,
|
|
565
|
+
output_tokens: output_tokens,
|
|
566
|
+
total_cost: total_cost
|
|
567
|
+
}
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# Access individual item results by index
|
|
571
|
+
def [](index)
|
|
572
|
+
item_results[index]
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def each(&block)
|
|
576
|
+
item_results.each(&block)
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
def map(&block)
|
|
580
|
+
item_results.map(&block)
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
include Enumerable
|
|
584
|
+
|
|
585
|
+
# Empty iteration result factory
|
|
586
|
+
def self.empty(step_name)
|
|
587
|
+
new(step_name: step_name, item_results: [], errors: {})
|
|
588
|
+
end
|
|
589
|
+
end
|
|
388
590
|
end
|
|
389
591
|
end
|
|
390
592
|
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
# Manages rate limiting and throttling for workflow steps
|
|
7
|
+
#
|
|
8
|
+
# Provides two modes of rate limiting:
|
|
9
|
+
# 1. Throttle: Ensures minimum time between executions of the same step
|
|
10
|
+
# 2. Rate limit: Limits the number of calls within a time window (token bucket)
|
|
11
|
+
#
|
|
12
|
+
# Thread-safe using a Mutex for concurrent access.
|
|
13
|
+
#
|
|
14
|
+
# @example Using throttle
|
|
15
|
+
# manager = ThrottleManager.new
|
|
16
|
+
# manager.throttle("step:fetch", 1.0) # Wait at least 1 second between calls
|
|
17
|
+
#
|
|
18
|
+
# @example Using rate limit
|
|
19
|
+
# manager = ThrottleManager.new
|
|
20
|
+
# manager.rate_limit("api:external", calls: 10, per: 60) # 10 calls per minute
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class ThrottleManager
|
|
24
|
+
def initialize
|
|
25
|
+
@last_execution = {}
|
|
26
|
+
@rate_limiters = {}
|
|
27
|
+
@mutex = Mutex.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Throttle execution to ensure minimum time between calls
|
|
31
|
+
#
|
|
32
|
+
# Blocks the current thread if necessary to maintain the minimum interval.
|
|
33
|
+
#
|
|
34
|
+
# @param key [String] Unique identifier for the throttle target
|
|
35
|
+
# @param duration [Float, Integer] Minimum seconds between executions
|
|
36
|
+
# @return [Float] Actual seconds waited (0 if no wait needed)
|
|
37
|
+
def throttle(key, duration)
|
|
38
|
+
duration_seconds = normalize_duration(duration)
|
|
39
|
+
|
|
40
|
+
@mutex.synchronize do
|
|
41
|
+
last = @last_execution[key]
|
|
42
|
+
waited = 0
|
|
43
|
+
|
|
44
|
+
if last
|
|
45
|
+
elapsed = Time.now - last
|
|
46
|
+
remaining = duration_seconds - elapsed
|
|
47
|
+
|
|
48
|
+
if remaining > 0
|
|
49
|
+
@mutex.sleep(remaining)
|
|
50
|
+
waited = remaining
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@last_execution[key] = Time.now
|
|
55
|
+
waited
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Check if a call would be throttled without actually waiting
|
|
60
|
+
#
|
|
61
|
+
# @param key [String] Unique identifier for the throttle target
|
|
62
|
+
# @param duration [Float, Integer] Minimum seconds between executions
|
|
63
|
+
# @return [Float] Seconds until next allowed execution (0 if ready)
|
|
64
|
+
def throttle_remaining(key, duration)
|
|
65
|
+
duration_seconds = normalize_duration(duration)
|
|
66
|
+
|
|
67
|
+
@mutex.synchronize do
|
|
68
|
+
last = @last_execution[key]
|
|
69
|
+
return 0 unless last
|
|
70
|
+
|
|
71
|
+
elapsed = Time.now - last
|
|
72
|
+
remaining = duration_seconds - elapsed
|
|
73
|
+
[remaining, 0].max
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Apply rate limiting using a token bucket algorithm
|
|
78
|
+
#
|
|
79
|
+
# Blocks until a token is available if the rate limit is exceeded.
|
|
80
|
+
#
|
|
81
|
+
# @param key [String] Unique identifier for the rate limit target
|
|
82
|
+
# @param calls [Integer] Number of calls allowed per window
|
|
83
|
+
# @param per [Float, Integer] Time window in seconds
|
|
84
|
+
# @return [Float] Seconds waited (0 if no wait needed)
|
|
85
|
+
def rate_limit(key, calls:, per:)
|
|
86
|
+
per_seconds = normalize_duration(per)
|
|
87
|
+
bucket = get_or_create_bucket(key, calls, per_seconds)
|
|
88
|
+
|
|
89
|
+
@mutex.synchronize do
|
|
90
|
+
waited = bucket.acquire
|
|
91
|
+
waited
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Check if a call would be rate limited without consuming a token
|
|
96
|
+
#
|
|
97
|
+
# @param key [String] Unique identifier for the rate limit target
|
|
98
|
+
# @param calls [Integer] Number of calls allowed per window
|
|
99
|
+
# @param per [Float, Integer] Time window in seconds
|
|
100
|
+
# @return [Boolean] true if a call would be allowed immediately
|
|
101
|
+
def rate_limit_available?(key, calls:, per:)
|
|
102
|
+
per_seconds = normalize_duration(per)
|
|
103
|
+
bucket = get_or_create_bucket(key, calls, per_seconds)
|
|
104
|
+
|
|
105
|
+
@mutex.synchronize do
|
|
106
|
+
bucket.available?
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Reset throttle state for a specific key
|
|
111
|
+
#
|
|
112
|
+
# @param key [String] The throttle key to reset
|
|
113
|
+
# @return [void]
|
|
114
|
+
def reset_throttle(key)
|
|
115
|
+
@mutex.synchronize do
|
|
116
|
+
@last_execution.delete(key)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Reset rate limiter state for a specific key
|
|
121
|
+
#
|
|
122
|
+
# @param key [String] The rate limiter key to reset
|
|
123
|
+
# @return [void]
|
|
124
|
+
def reset_rate_limit(key)
|
|
125
|
+
@mutex.synchronize do
|
|
126
|
+
@rate_limiters.delete(key)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reset all throttle and rate limit state
|
|
131
|
+
#
|
|
132
|
+
# @return [void]
|
|
133
|
+
def reset_all!
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
@last_execution.clear
|
|
136
|
+
@rate_limiters.clear
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def normalize_duration(duration)
|
|
143
|
+
if duration.respond_to?(:to_f)
|
|
144
|
+
duration.to_f
|
|
145
|
+
else
|
|
146
|
+
duration.to_i.to_f
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def get_or_create_bucket(key, calls, per)
|
|
151
|
+
@rate_limiters[key] ||= TokenBucket.new(calls, per)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Simple token bucket implementation for rate limiting
|
|
155
|
+
#
|
|
156
|
+
# @api private
|
|
157
|
+
class TokenBucket
|
|
158
|
+
def initialize(capacity, refill_time)
|
|
159
|
+
@capacity = capacity
|
|
160
|
+
@refill_time = refill_time
|
|
161
|
+
@tokens = capacity.to_f
|
|
162
|
+
@last_refill = Time.now
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Try to acquire a token, waiting if necessary
|
|
166
|
+
#
|
|
167
|
+
# @return [Float] Seconds waited
|
|
168
|
+
def acquire
|
|
169
|
+
refill
|
|
170
|
+
waited = 0
|
|
171
|
+
|
|
172
|
+
if @tokens < 1
|
|
173
|
+
# Calculate wait time for next token
|
|
174
|
+
tokens_needed = 1 - @tokens
|
|
175
|
+
wait_time = tokens_needed * @refill_time / @capacity
|
|
176
|
+
sleep(wait_time)
|
|
177
|
+
waited = wait_time
|
|
178
|
+
refill
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@tokens -= 1
|
|
182
|
+
waited
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Check if a token is available without consuming it
|
|
186
|
+
#
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
def available?
|
|
189
|
+
refill
|
|
190
|
+
@tokens >= 1
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def refill
|
|
196
|
+
now = Time.now
|
|
197
|
+
elapsed = now - @last_refill
|
|
198
|
+
refill_amount = elapsed * @capacity / @refill_time
|
|
199
|
+
@tokens = [@tokens + refill_amount, @capacity].min
|
|
200
|
+
@last_refill = now
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|