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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../wait_result"
|
|
4
|
+
require_relative "../throttle_manager"
|
|
5
|
+
require_relative "../approval"
|
|
6
|
+
require_relative "../approval_store"
|
|
7
|
+
require_relative "../notifiers"
|
|
8
|
+
|
|
9
|
+
module RubyLLM
|
|
10
|
+
module Agents
|
|
11
|
+
class Workflow
|
|
12
|
+
module DSL
|
|
13
|
+
# Main executor for workflows using the refined DSL
|
|
14
|
+
#
|
|
15
|
+
# Handles the execution of steps in order, including sequential
|
|
16
|
+
# steps and parallel groups, with full support for routing,
|
|
17
|
+
# conditions, retries, and error handling.
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class Executor
|
|
21
|
+
attr_reader :workflow, :results, :errors, :status
|
|
22
|
+
|
|
23
|
+
# @param workflow [Workflow] The workflow instance
|
|
24
|
+
def initialize(workflow)
|
|
25
|
+
@workflow = workflow
|
|
26
|
+
@results = {}
|
|
27
|
+
@errors = {}
|
|
28
|
+
@status = "success"
|
|
29
|
+
@halted = false
|
|
30
|
+
@skip_next_step = false
|
|
31
|
+
@throttle_manager = ThrottleManager.new
|
|
32
|
+
@wait_results = {}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Executes all workflow steps
|
|
36
|
+
#
|
|
37
|
+
# @yield [chunk] Streaming callback
|
|
38
|
+
# @return [Workflow::Result] The workflow result
|
|
39
|
+
def execute(&block)
|
|
40
|
+
@workflow_started_at = Time.current
|
|
41
|
+
|
|
42
|
+
# Validate input schema before execution
|
|
43
|
+
validate_input!
|
|
44
|
+
|
|
45
|
+
run_hooks(:before_workflow)
|
|
46
|
+
|
|
47
|
+
catch(:halt_workflow) do
|
|
48
|
+
execute_steps(&block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
run_hooks(:after_workflow)
|
|
52
|
+
|
|
53
|
+
build_result
|
|
54
|
+
rescue InputSchema::ValidationError
|
|
55
|
+
# Re-raise validation errors - these should not be caught
|
|
56
|
+
raise
|
|
57
|
+
rescue StandardError => e
|
|
58
|
+
@status = "error"
|
|
59
|
+
@errors[:workflow] = e
|
|
60
|
+
build_result(error: e)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Validates input against the schema if defined
|
|
66
|
+
#
|
|
67
|
+
# This is called at the start of execution to fail fast on invalid input.
|
|
68
|
+
# Also populates the validated_input for later access.
|
|
69
|
+
#
|
|
70
|
+
# @raise [InputSchema::ValidationError] If input validation fails
|
|
71
|
+
def validate_input!
|
|
72
|
+
schema = workflow.class.input_schema
|
|
73
|
+
return unless schema
|
|
74
|
+
|
|
75
|
+
# This will raise ValidationError if input is invalid
|
|
76
|
+
validated = schema.validate!(workflow.options)
|
|
77
|
+
workflow.instance_variable_set(:@validated_input, OpenStruct.new(validated))
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute_steps(&block)
|
|
81
|
+
previous_result = nil
|
|
82
|
+
|
|
83
|
+
workflow.class.step_order.each do |item|
|
|
84
|
+
break if @halted
|
|
85
|
+
|
|
86
|
+
# Handle skip_next from wait timeout
|
|
87
|
+
if @skip_next_step
|
|
88
|
+
@skip_next_step = false
|
|
89
|
+
next if item.is_a?(Symbol)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
case item
|
|
93
|
+
when Symbol
|
|
94
|
+
previous_result = execute_single_step(item, previous_result, &block)
|
|
95
|
+
when ParallelGroup
|
|
96
|
+
previous_result = execute_parallel_group(item, &block)
|
|
97
|
+
when WaitConfig
|
|
98
|
+
wait_result = execute_wait_step(item)
|
|
99
|
+
@wait_results[item.object_id] = wait_result
|
|
100
|
+
handle_wait_result(wait_result)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def execute_single_step(step_name, previous_result, &block)
|
|
106
|
+
config = workflow.class.step_configs[step_name]
|
|
107
|
+
return previous_result unless config
|
|
108
|
+
|
|
109
|
+
# Apply throttling if configured
|
|
110
|
+
apply_throttle(step_name, config)
|
|
111
|
+
|
|
112
|
+
run_hooks(:before_step, step_name, workflow.step_results)
|
|
113
|
+
run_hooks(:on_step_start, step_name, config.resolve_input(workflow, previous_result))
|
|
114
|
+
|
|
115
|
+
started_at = Time.current
|
|
116
|
+
|
|
117
|
+
result = catch(:skip_step) do
|
|
118
|
+
executor = StepExecutor.new(workflow, config)
|
|
119
|
+
executor.execute(previous_result, &block)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Handle skip_step catch
|
|
123
|
+
if result.is_a?(Hash) && result[:skipped]
|
|
124
|
+
result = if result[:default]
|
|
125
|
+
SimpleResult.new(content: result[:default], success: true)
|
|
126
|
+
else
|
|
127
|
+
SkippedResult.new(step_name, reason: result[:reason])
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
duration_ms = ((Time.current - started_at) * 1000).round
|
|
132
|
+
|
|
133
|
+
@results[step_name] = result
|
|
134
|
+
workflow.instance_variable_get(:@step_results)[step_name] = result
|
|
135
|
+
|
|
136
|
+
# Update status based on result
|
|
137
|
+
update_status_from_result(step_name, result, config)
|
|
138
|
+
|
|
139
|
+
run_hooks(:after_step, step_name, result, duration_ms)
|
|
140
|
+
run_hooks(:on_step_complete, step_name, result, duration_ms)
|
|
141
|
+
|
|
142
|
+
# Return nil on error for critical steps to prevent passing bad data
|
|
143
|
+
if result.respond_to?(:error?) && result.error? && config.critical?
|
|
144
|
+
@halted = true
|
|
145
|
+
return nil
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
result
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
handle_step_error(step_name, e, config)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def execute_parallel_group(group, &block)
|
|
154
|
+
results_mutex = Mutex.new
|
|
155
|
+
group_results = {}
|
|
156
|
+
group_errors = {}
|
|
157
|
+
|
|
158
|
+
# Determine pool size
|
|
159
|
+
pool_size = group.concurrency || group.step_names.size
|
|
160
|
+
pool = create_executor_pool(pool_size)
|
|
161
|
+
|
|
162
|
+
# Get the last result before this parallel group for input
|
|
163
|
+
last_sequential_step = workflow.class.step_order
|
|
164
|
+
.take_while { |item| item != group }
|
|
165
|
+
.select { |item| item.is_a?(Symbol) }
|
|
166
|
+
.last
|
|
167
|
+
previous_result = last_sequential_step ? @results[last_sequential_step] : nil
|
|
168
|
+
|
|
169
|
+
group.step_names.each do |step_name|
|
|
170
|
+
pool.post do
|
|
171
|
+
Thread.current.name = "parallel-#{step_name}"
|
|
172
|
+
|
|
173
|
+
begin
|
|
174
|
+
config = workflow.class.step_configs[step_name]
|
|
175
|
+
next unless config
|
|
176
|
+
|
|
177
|
+
executor = StepExecutor.new(workflow, config)
|
|
178
|
+
result = executor.execute(previous_result, &block)
|
|
179
|
+
|
|
180
|
+
results_mutex.synchronize do
|
|
181
|
+
group_results[step_name] = result
|
|
182
|
+
@results[step_name] = result
|
|
183
|
+
workflow.instance_variable_get(:@step_results)[step_name] = result
|
|
184
|
+
|
|
185
|
+
# Fail-fast handling
|
|
186
|
+
if group.fail_fast? && result.respond_to?(:error?) && result.error? && config.critical?
|
|
187
|
+
pool.abort! if pool.respond_to?(:abort!)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
results_mutex.synchronize do
|
|
192
|
+
group_errors[step_name] = e
|
|
193
|
+
@errors[step_name] = e
|
|
194
|
+
|
|
195
|
+
if group.fail_fast?
|
|
196
|
+
pool.abort! if pool.respond_to?(:abort!)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
pool.wait_for_completion
|
|
204
|
+
pool.shutdown
|
|
205
|
+
|
|
206
|
+
# Update overall status
|
|
207
|
+
update_parallel_status(group, group_results, group_errors)
|
|
208
|
+
|
|
209
|
+
# Return combined results as a hash-like object
|
|
210
|
+
ParallelGroupResult.new(group.name, group_results)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def create_executor_pool(size)
|
|
214
|
+
config = RubyLLM::Agents.configuration
|
|
215
|
+
|
|
216
|
+
if config.respond_to?(:async_context?) && config.async_context?
|
|
217
|
+
AsyncExecutor.new(max_concurrent: size)
|
|
218
|
+
else
|
|
219
|
+
ThreadPool.new(size: size)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Executes a wait step
|
|
224
|
+
#
|
|
225
|
+
# @param wait_config [WaitConfig] The wait configuration
|
|
226
|
+
# @return [WaitResult] The wait result
|
|
227
|
+
def execute_wait_step(wait_config)
|
|
228
|
+
executor = WaitExecutor.new(wait_config, workflow)
|
|
229
|
+
executor.execute
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
# Return a failed result instead of crashing
|
|
232
|
+
Workflow::WaitResult.timeout(
|
|
233
|
+
wait_config.type,
|
|
234
|
+
0,
|
|
235
|
+
:fail,
|
|
236
|
+
error: "#{e.class}: #{e.message}"
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Handles the result of a wait step
|
|
241
|
+
#
|
|
242
|
+
# @param wait_result [WaitResult] The wait result
|
|
243
|
+
# @return [void]
|
|
244
|
+
def handle_wait_result(wait_result)
|
|
245
|
+
if wait_result.timeout? && wait_result.timeout_action == :fail
|
|
246
|
+
@status = "error"
|
|
247
|
+
@halted = true
|
|
248
|
+
@errors[:wait] = "Wait timed out: #{wait_result.type}"
|
|
249
|
+
elsif wait_result.rejected?
|
|
250
|
+
@status = "error"
|
|
251
|
+
@halted = true
|
|
252
|
+
@errors[:wait] = "Approval rejected: #{wait_result.rejection_reason}"
|
|
253
|
+
elsif wait_result.should_skip_next?
|
|
254
|
+
@skip_next_step = true
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Applies throttling for a step if configured
|
|
259
|
+
#
|
|
260
|
+
# @param step_name [Symbol] The step name
|
|
261
|
+
# @param config [StepConfig] The step configuration
|
|
262
|
+
# @return [void]
|
|
263
|
+
def apply_throttle(step_name, config)
|
|
264
|
+
return unless config.throttled?
|
|
265
|
+
|
|
266
|
+
if config.throttle
|
|
267
|
+
@throttle_manager.throttle("step:#{step_name}", config.throttle)
|
|
268
|
+
elsif config.rate_limit
|
|
269
|
+
@throttle_manager.rate_limit(
|
|
270
|
+
"step:#{step_name}",
|
|
271
|
+
calls: config.rate_limit[:calls],
|
|
272
|
+
per: config.rate_limit[:per]
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def handle_step_error(step_name, error, config)
|
|
278
|
+
@errors[step_name] = error
|
|
279
|
+
|
|
280
|
+
run_hooks(:on_step_error, step_name, error)
|
|
281
|
+
run_hooks(:on_step_failure, step_name, error, workflow.step_results)
|
|
282
|
+
|
|
283
|
+
# Build error result
|
|
284
|
+
error_result = Pipeline::ErrorResult.new(
|
|
285
|
+
step_name: step_name,
|
|
286
|
+
error_class: error.class.name,
|
|
287
|
+
error_message: error.message
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
@results[step_name] = error_result
|
|
291
|
+
workflow.instance_variable_get(:@step_results)[step_name] = error_result
|
|
292
|
+
|
|
293
|
+
if config.optional?
|
|
294
|
+
@status = "partial" if @status == "success"
|
|
295
|
+
config.default_value ? SimpleResult.new(content: config.default_value, success: true) : nil
|
|
296
|
+
else
|
|
297
|
+
@status = "error"
|
|
298
|
+
@halted = true
|
|
299
|
+
nil
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def update_status_from_result(step_name, result, config)
|
|
304
|
+
return unless result.respond_to?(:error?) && result.error?
|
|
305
|
+
|
|
306
|
+
if config.optional?
|
|
307
|
+
@status = "partial" if @status == "success"
|
|
308
|
+
else
|
|
309
|
+
@status = "error"
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def update_parallel_status(group, group_results, group_errors)
|
|
314
|
+
# Check for errors
|
|
315
|
+
group.step_names.each do |step_name|
|
|
316
|
+
config = workflow.class.step_configs[step_name]
|
|
317
|
+
|
|
318
|
+
if group_errors[step_name]
|
|
319
|
+
if config&.optional?
|
|
320
|
+
@status = "partial" if @status == "success"
|
|
321
|
+
else
|
|
322
|
+
@status = "error"
|
|
323
|
+
end
|
|
324
|
+
elsif group_results[step_name]&.respond_to?(:error?) && group_results[step_name].error?
|
|
325
|
+
if config&.optional?
|
|
326
|
+
@status = "partial" if @status == "success"
|
|
327
|
+
else
|
|
328
|
+
@status = "error"
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def run_hooks(hook_name, *args)
|
|
335
|
+
workflow.send(:run_hooks, hook_name, *args)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def build_result(error: nil)
|
|
339
|
+
# Get final content from last successful step
|
|
340
|
+
final_content = extract_final_content
|
|
341
|
+
|
|
342
|
+
# Validate output if schema defined
|
|
343
|
+
if workflow.class.output_schema && final_content
|
|
344
|
+
begin
|
|
345
|
+
workflow.class.output_schema.validate!(final_content)
|
|
346
|
+
rescue InputSchema::ValidationError => e
|
|
347
|
+
@errors[:output_validation] = e
|
|
348
|
+
@status = "error" if @status == "success"
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
Workflow::Result.new(
|
|
353
|
+
content: final_content,
|
|
354
|
+
workflow_type: workflow.class.name,
|
|
355
|
+
workflow_id: workflow.workflow_id,
|
|
356
|
+
steps: @results,
|
|
357
|
+
errors: @errors,
|
|
358
|
+
status: @status,
|
|
359
|
+
error_class: error&.class&.name,
|
|
360
|
+
error_message: error&.message,
|
|
361
|
+
started_at: @workflow_started_at,
|
|
362
|
+
completed_at: Time.current,
|
|
363
|
+
duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
|
|
364
|
+
)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def extract_final_content
|
|
368
|
+
# Find the last successful result
|
|
369
|
+
workflow.class.step_order.reverse.each do |item|
|
|
370
|
+
case item
|
|
371
|
+
when Symbol
|
|
372
|
+
result = @results[item]
|
|
373
|
+
next if result.nil?
|
|
374
|
+
next if result.respond_to?(:skipped?) && result.skipped?
|
|
375
|
+
next if result.respond_to?(:error?) && result.error?
|
|
376
|
+
return result.content if result.respond_to?(:content)
|
|
377
|
+
when ParallelGroup
|
|
378
|
+
# For parallel groups, return the combined content
|
|
379
|
+
group_content = {}
|
|
380
|
+
item.step_names.each do |step_name|
|
|
381
|
+
result = @results[step_name]
|
|
382
|
+
next if result.nil? || (result.respond_to?(:error?) && result.error?)
|
|
383
|
+
group_content[step_name] = result.respond_to?(:content) ? result.content : result
|
|
384
|
+
end
|
|
385
|
+
return group_content if group_content.any?
|
|
386
|
+
when WaitConfig
|
|
387
|
+
# Wait steps don't contribute content, skip them
|
|
388
|
+
next
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
nil
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Result wrapper for parallel group execution
|
|
397
|
+
#
|
|
398
|
+
# Provides access to individual step results within a parallel group.
|
|
399
|
+
#
|
|
400
|
+
# @api private
|
|
401
|
+
class ParallelGroupResult
|
|
402
|
+
attr_reader :name, :results
|
|
403
|
+
|
|
404
|
+
def initialize(name, results)
|
|
405
|
+
@name = name
|
|
406
|
+
@results = results
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def content
|
|
410
|
+
@results.transform_values { |r| r&.content }
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def [](key)
|
|
414
|
+
@results[key]
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def success?
|
|
418
|
+
@results.values.all? { |r| r.nil? || !r.respond_to?(:error?) || !r.error? }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def error?
|
|
422
|
+
!success?
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def to_h
|
|
426
|
+
content
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def method_missing(name, *args, &block)
|
|
430
|
+
if @results.key?(name)
|
|
431
|
+
@results[name]
|
|
432
|
+
elsif content.key?(name)
|
|
433
|
+
content[name]
|
|
434
|
+
else
|
|
435
|
+
super
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def respond_to_missing?(name, include_private = false)
|
|
440
|
+
@results.key?(name) || content.key?(name) || super
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Token/cost aggregation
|
|
444
|
+
def input_tokens
|
|
445
|
+
@results.values.compact.sum { |r| r.respond_to?(:input_tokens) ? r.input_tokens : 0 }
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def output_tokens
|
|
449
|
+
@results.values.compact.sum { |r| r.respond_to?(:output_tokens) ? r.output_tokens : 0 }
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def total_tokens
|
|
453
|
+
input_tokens + output_tokens
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def cached_tokens
|
|
457
|
+
@results.values.compact.sum { |r| r.respond_to?(:cached_tokens) ? r.cached_tokens : 0 }
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def total_cost
|
|
461
|
+
@results.values.compact.sum { |r| r.respond_to?(:total_cost) ? r.total_cost : 0.0 }
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
module DSL
|
|
7
|
+
# Defines and validates input schema for a workflow
|
|
8
|
+
#
|
|
9
|
+
# Provides a DSL for declaring required and optional input parameters
|
|
10
|
+
# with type validation and default values.
|
|
11
|
+
#
|
|
12
|
+
# @example Defining input schema
|
|
13
|
+
# class MyWorkflow < RubyLLM::Agents::Workflow
|
|
14
|
+
# input do
|
|
15
|
+
# required :order_id, String
|
|
16
|
+
# required :user_id, Integer
|
|
17
|
+
# optional :priority, String, default: "normal"
|
|
18
|
+
# optional :expedited, Boolean, default: false
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# @api private
|
|
23
|
+
class InputSchema
|
|
24
|
+
# Error raised when input validation fails
|
|
25
|
+
class ValidationError < StandardError
|
|
26
|
+
attr_reader :errors
|
|
27
|
+
|
|
28
|
+
def initialize(message, errors: [])
|
|
29
|
+
super(message)
|
|
30
|
+
@errors = errors
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Represents a single field in the schema
|
|
35
|
+
class Field
|
|
36
|
+
attr_reader :name, :type, :required, :default, :options
|
|
37
|
+
|
|
38
|
+
def initialize(name, type, required:, default: nil, **options)
|
|
39
|
+
@name = name
|
|
40
|
+
@type = type
|
|
41
|
+
@required = required
|
|
42
|
+
@default = default
|
|
43
|
+
@options = options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def required?
|
|
47
|
+
@required
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def optional?
|
|
51
|
+
!@required
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def has_default?
|
|
55
|
+
!@default.nil? || @options.key?(:default)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def validate(value)
|
|
59
|
+
errors = []
|
|
60
|
+
|
|
61
|
+
# Check required
|
|
62
|
+
if required? && value.nil?
|
|
63
|
+
errors << "#{name} is required"
|
|
64
|
+
return errors
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Skip validation for nil optional values
|
|
68
|
+
return errors if value.nil? && optional?
|
|
69
|
+
|
|
70
|
+
# Type validation
|
|
71
|
+
unless valid_type?(value)
|
|
72
|
+
errors << "#{name} must be a #{type_description}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Enum validation
|
|
76
|
+
if options[:in] && !options[:in].include?(value)
|
|
77
|
+
errors << "#{name} must be one of: #{options[:in].join(', ')}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Custom validation
|
|
81
|
+
if options[:validate] && !options[:validate].call(value)
|
|
82
|
+
errors << "#{name} failed custom validation"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
errors
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
{
|
|
90
|
+
name: name,
|
|
91
|
+
type: type_description,
|
|
92
|
+
required: required?,
|
|
93
|
+
default: default,
|
|
94
|
+
options: options.except(:validate)
|
|
95
|
+
}.compact
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def valid_type?(value)
|
|
101
|
+
return true if type.nil?
|
|
102
|
+
|
|
103
|
+
case type
|
|
104
|
+
when :boolean, "Boolean"
|
|
105
|
+
value == true || value == false
|
|
106
|
+
else
|
|
107
|
+
value.is_a?(type)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def type_description
|
|
112
|
+
case type
|
|
113
|
+
when :boolean, "Boolean"
|
|
114
|
+
"Boolean"
|
|
115
|
+
when Class
|
|
116
|
+
type.name
|
|
117
|
+
else
|
|
118
|
+
type.to_s
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def initialize
|
|
124
|
+
@fields = {}
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Defines a required field
|
|
128
|
+
#
|
|
129
|
+
# @param name [Symbol] Field name
|
|
130
|
+
# @param type [Class, Symbol] Expected type
|
|
131
|
+
# @param options [Hash] Additional options
|
|
132
|
+
# @return [void]
|
|
133
|
+
def required(name, type = nil, **options)
|
|
134
|
+
@fields[name] = Field.new(name, type, required: true, **options)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Defines an optional field
|
|
138
|
+
#
|
|
139
|
+
# @param name [Symbol] Field name
|
|
140
|
+
# @param type [Class, Symbol] Expected type
|
|
141
|
+
# @param default [Object] Default value
|
|
142
|
+
# @param options [Hash] Additional options
|
|
143
|
+
# @return [void]
|
|
144
|
+
def optional(name, type = nil, default: nil, **options)
|
|
145
|
+
@fields[name] = Field.new(name, type, required: false, default: default, **options)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Returns all fields
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash<Symbol, Field>]
|
|
151
|
+
attr_reader :fields
|
|
152
|
+
|
|
153
|
+
# Returns required field names
|
|
154
|
+
#
|
|
155
|
+
# @return [Array<Symbol>]
|
|
156
|
+
def required_fields
|
|
157
|
+
@fields.select { |_, f| f.required? }.keys
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Returns optional field names
|
|
161
|
+
#
|
|
162
|
+
# @return [Array<Symbol>]
|
|
163
|
+
def optional_fields
|
|
164
|
+
@fields.select { |_, f| f.optional? }.keys
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Validates input against the schema
|
|
168
|
+
#
|
|
169
|
+
# @param input [Hash] Input data to validate
|
|
170
|
+
# @return [Hash] Validated and normalized input
|
|
171
|
+
# @raise [ValidationError] If validation fails
|
|
172
|
+
def validate!(input)
|
|
173
|
+
errors = []
|
|
174
|
+
normalized = {}
|
|
175
|
+
|
|
176
|
+
@fields.each do |name, field|
|
|
177
|
+
value = input.key?(name) ? input[name] : field.default
|
|
178
|
+
field_errors = field.validate(value)
|
|
179
|
+
errors.concat(field_errors)
|
|
180
|
+
normalized[name] = value unless value.nil? && field.optional?
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Include any extra fields not in schema
|
|
184
|
+
input.each do |key, value|
|
|
185
|
+
normalized[key] = value unless @fields.key?(key)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
if errors.any?
|
|
189
|
+
raise ValidationError.new(
|
|
190
|
+
"Input validation failed: #{errors.join(', ')}",
|
|
191
|
+
errors: errors
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
normalized
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Applies defaults to input without validation
|
|
199
|
+
#
|
|
200
|
+
# @param input [Hash] Input data
|
|
201
|
+
# @return [Hash] Input with defaults applied
|
|
202
|
+
def apply_defaults(input)
|
|
203
|
+
result = input.dup
|
|
204
|
+
@fields.each do |name, field|
|
|
205
|
+
result[name] = field.default if !result.key?(name) && field.has_default?
|
|
206
|
+
end
|
|
207
|
+
result
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Converts to hash for serialization
|
|
211
|
+
#
|
|
212
|
+
# @return [Hash]
|
|
213
|
+
def to_h
|
|
214
|
+
{
|
|
215
|
+
fields: @fields.transform_values(&:to_h)
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Returns whether the schema is empty
|
|
220
|
+
#
|
|
221
|
+
# @return [Boolean]
|
|
222
|
+
def empty?
|
|
223
|
+
@fields.empty?
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Output schema for workflow results
|
|
228
|
+
#
|
|
229
|
+
# Similar to InputSchema but for validating workflow output.
|
|
230
|
+
class OutputSchema < InputSchema
|
|
231
|
+
# Validates output against the schema
|
|
232
|
+
#
|
|
233
|
+
# @param output [Hash] Output data to validate
|
|
234
|
+
# @return [Hash] Validated output
|
|
235
|
+
# @raise [ValidationError] If validation fails
|
|
236
|
+
def validate!(output)
|
|
237
|
+
output_hash = output.is_a?(Hash) ? output : { result: output }
|
|
238
|
+
super(output_hash)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|