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,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
module DSL
|
|
7
|
+
# Executes individual workflow steps with retry, timeout, and error handling
|
|
8
|
+
#
|
|
9
|
+
# Responsible for:
|
|
10
|
+
# - Evaluating step conditions
|
|
11
|
+
# - Building step input
|
|
12
|
+
# - Executing agents with timeout
|
|
13
|
+
# - Handling retries with backoff
|
|
14
|
+
# - Executing fallback agents
|
|
15
|
+
# - Invoking error handlers
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class StepExecutor
|
|
19
|
+
attr_reader :workflow, :config
|
|
20
|
+
|
|
21
|
+
# @param workflow [Workflow] The workflow instance
|
|
22
|
+
# @param config [StepConfig] The step configuration
|
|
23
|
+
def initialize(workflow, config)
|
|
24
|
+
@workflow = workflow
|
|
25
|
+
@config = config
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Executes the step
|
|
29
|
+
#
|
|
30
|
+
# @param previous_result [Result, nil] Previous step result
|
|
31
|
+
# @yield [chunk] Streaming callback
|
|
32
|
+
# @return [Result, SkippedResult] Step result
|
|
33
|
+
def execute(previous_result = nil, &block)
|
|
34
|
+
# Check conditions
|
|
35
|
+
unless config.should_execute?(workflow)
|
|
36
|
+
return create_skipped_result("condition not met")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Execute with timeout wrapper if configured
|
|
40
|
+
if config.timeout
|
|
41
|
+
execute_with_timeout(previous_result, &block)
|
|
42
|
+
else
|
|
43
|
+
execute_step(previous_result, &block)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def execute_with_timeout(previous_result, &block)
|
|
50
|
+
Timeout.timeout(config.timeout) do
|
|
51
|
+
execute_step(previous_result, &block)
|
|
52
|
+
end
|
|
53
|
+
rescue Timeout::Error => e
|
|
54
|
+
handle_step_error(e, previous_result, &block)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_step(previous_result, &block)
|
|
58
|
+
execute_with_retry(previous_result, &block)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_step_error(e, previous_result, &block)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def execute_with_retry(previous_result, &block)
|
|
64
|
+
retry_config = config.retry_config
|
|
65
|
+
max_attempts = [retry_config[:max], 0].max + 1
|
|
66
|
+
attempts = 0
|
|
67
|
+
|
|
68
|
+
begin
|
|
69
|
+
attempts += 1
|
|
70
|
+
execute_agent_or_block(previous_result, &block)
|
|
71
|
+
rescue *retry_config[:on] => e
|
|
72
|
+
if attempts < max_attempts
|
|
73
|
+
sleep_with_backoff(retry_config, attempts)
|
|
74
|
+
retry
|
|
75
|
+
else
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def execute_agent_or_block(previous_result, &block)
|
|
82
|
+
if config.routing?
|
|
83
|
+
execute_routed_step(previous_result, &block)
|
|
84
|
+
elsif config.iteration?
|
|
85
|
+
execute_iteration_step(previous_result, &block)
|
|
86
|
+
elsif config.workflow?
|
|
87
|
+
execute_workflow_step(previous_result, &block)
|
|
88
|
+
elsif config.custom_block?
|
|
89
|
+
execute_block_step(previous_result)
|
|
90
|
+
else
|
|
91
|
+
execute_agent_step(previous_result, &block)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def execute_routed_step(previous_result, &block)
|
|
96
|
+
route = config.resolve_route(workflow)
|
|
97
|
+
agent_class = route[:agent]
|
|
98
|
+
route_options = route[:options] || {}
|
|
99
|
+
|
|
100
|
+
# Build input - use route-specific input if provided
|
|
101
|
+
step_input = if route_options[:input]
|
|
102
|
+
workflow.instance_exec(&route_options[:input])
|
|
103
|
+
else
|
|
104
|
+
config.resolve_input(workflow, previous_result)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Execute the routed agent
|
|
108
|
+
workflow.send(:execute_agent, agent_class, step_input, step_name: config.name, &block)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def execute_block_step(previous_result)
|
|
112
|
+
# Create a block context that provides helper methods
|
|
113
|
+
context = BlockContext.new(workflow, config, previous_result)
|
|
114
|
+
result = context.instance_exec(&config.block)
|
|
115
|
+
|
|
116
|
+
# If block returns a Result, use it; otherwise wrap it
|
|
117
|
+
if result.is_a?(Result) || result.is_a?(Workflow::Result)
|
|
118
|
+
result
|
|
119
|
+
else
|
|
120
|
+
SimpleResult.new(content: result, success: true)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def execute_agent_step(previous_result, &block)
|
|
125
|
+
step_input = config.resolve_input(workflow, previous_result)
|
|
126
|
+
workflow.send(:execute_agent, config.agent, step_input, step_name: config.name, &block)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def execute_workflow_step(previous_result, &block)
|
|
130
|
+
step_input = config.resolve_input(workflow, previous_result)
|
|
131
|
+
|
|
132
|
+
# Build execution metadata for the sub-workflow
|
|
133
|
+
parent_metadata = {
|
|
134
|
+
parent_execution_id: workflow.execution_id,
|
|
135
|
+
root_execution_id: workflow.send(:root_execution_id),
|
|
136
|
+
workflow_id: workflow.workflow_id,
|
|
137
|
+
workflow_type: workflow.class.name,
|
|
138
|
+
workflow_step: config.name.to_s,
|
|
139
|
+
remaining_timeout: calculate_remaining_timeout,
|
|
140
|
+
remaining_cost_budget: calculate_remaining_cost_budget,
|
|
141
|
+
recursion_depth: (workflow.instance_variable_get(:@recursion_depth) || 0) + (self_referential_workflow? ? 1 : 0)
|
|
142
|
+
}.compact
|
|
143
|
+
|
|
144
|
+
# Merge execution metadata into input
|
|
145
|
+
merged_input = step_input.merge(
|
|
146
|
+
execution_metadata: parent_metadata.merge(step_input[:execution_metadata] || {})
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Execute the sub-workflow
|
|
150
|
+
result = config.agent.call(**merged_input, &block)
|
|
151
|
+
|
|
152
|
+
# Track accumulated cost
|
|
153
|
+
if result.respond_to?(:total_cost) && result.total_cost
|
|
154
|
+
workflow.instance_variable_set(
|
|
155
|
+
:@accumulated_cost,
|
|
156
|
+
(workflow.instance_variable_get(:@accumulated_cost) || 0.0) + result.total_cost
|
|
157
|
+
)
|
|
158
|
+
workflow.send(:check_cost_threshold!)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Wrap in SubWorkflowResult for proper tracking
|
|
162
|
+
SubWorkflowResult.new(
|
|
163
|
+
content: result.content,
|
|
164
|
+
sub_workflow_result: result,
|
|
165
|
+
workflow_type: config.agent.name,
|
|
166
|
+
step_name: config.name
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def execute_iteration_step(previous_result, &block)
|
|
171
|
+
executor = IterationExecutor.new(workflow, config, previous_result)
|
|
172
|
+
executor.execute(&block)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def calculate_remaining_timeout
|
|
176
|
+
workflow_timeout = workflow.class.timeout
|
|
177
|
+
return nil unless workflow_timeout
|
|
178
|
+
|
|
179
|
+
started_at = workflow.instance_variable_get(:@workflow_started_at)
|
|
180
|
+
return workflow_timeout unless started_at
|
|
181
|
+
|
|
182
|
+
elapsed = Time.current - started_at
|
|
183
|
+
remaining = workflow_timeout - elapsed
|
|
184
|
+
remaining > 0 ? remaining.to_i : 1
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def calculate_remaining_cost_budget
|
|
188
|
+
max_cost = workflow.class.max_cost
|
|
189
|
+
return nil unless max_cost
|
|
190
|
+
|
|
191
|
+
accumulated = workflow.instance_variable_get(:@accumulated_cost) || 0.0
|
|
192
|
+
remaining = max_cost - accumulated
|
|
193
|
+
remaining > 0 ? remaining : 0.0
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def self_referential_workflow?
|
|
197
|
+
config.agent == workflow.class
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def handle_step_error(error, previous_result, &block)
|
|
201
|
+
# Try fallbacks first
|
|
202
|
+
if config.fallbacks.any?
|
|
203
|
+
fallback_result = try_fallbacks(previous_result, &block)
|
|
204
|
+
return fallback_result if fallback_result
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Try error handler
|
|
208
|
+
if config.error_handler
|
|
209
|
+
handler_result = invoke_error_handler(error)
|
|
210
|
+
return handler_result if handler_result.is_a?(Result) || handler_result.is_a?(Workflow::Result)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# If optional, return default or error result
|
|
214
|
+
if config.optional?
|
|
215
|
+
if config.default_value
|
|
216
|
+
return SimpleResult.new(content: config.default_value, success: true)
|
|
217
|
+
else
|
|
218
|
+
# Return an error result so status is set to "partial"
|
|
219
|
+
return Pipeline::ErrorResult.new(
|
|
220
|
+
step_name: config.name,
|
|
221
|
+
error_class: error.class.name,
|
|
222
|
+
error_message: error.message
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Re-raise for critical steps
|
|
228
|
+
raise
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def try_fallbacks(previous_result, &block)
|
|
232
|
+
step_input = config.resolve_input(workflow, previous_result)
|
|
233
|
+
|
|
234
|
+
config.fallbacks.each do |fallback_agent|
|
|
235
|
+
begin
|
|
236
|
+
return workflow.send(:execute_agent, fallback_agent, step_input, step_name: config.name, &block)
|
|
237
|
+
rescue StandardError
|
|
238
|
+
# Continue to next fallback
|
|
239
|
+
next
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def invoke_error_handler(error)
|
|
247
|
+
handler = config.error_handler
|
|
248
|
+
|
|
249
|
+
case handler
|
|
250
|
+
when Symbol
|
|
251
|
+
workflow.send(handler, error)
|
|
252
|
+
when Proc
|
|
253
|
+
workflow.instance_exec(error, &handler)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def sleep_with_backoff(retry_config, attempt)
|
|
258
|
+
base_delay = retry_config[:delay] || 1
|
|
259
|
+
|
|
260
|
+
delay = case retry_config[:backoff]
|
|
261
|
+
when :exponential
|
|
262
|
+
base_delay * (2**(attempt - 1))
|
|
263
|
+
when :linear
|
|
264
|
+
base_delay * attempt
|
|
265
|
+
else
|
|
266
|
+
base_delay
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sleep(delay)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def create_skipped_result(reason)
|
|
273
|
+
SkippedResult.new(config.name, reason: reason)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Context for executing custom block steps
|
|
278
|
+
#
|
|
279
|
+
# Provides helper methods available inside step blocks.
|
|
280
|
+
#
|
|
281
|
+
# @api private
|
|
282
|
+
class BlockContext
|
|
283
|
+
def initialize(workflow, config, previous_result)
|
|
284
|
+
@workflow = workflow
|
|
285
|
+
@config = config
|
|
286
|
+
@previous_result = previous_result
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Executes an agent within the block
|
|
290
|
+
#
|
|
291
|
+
# @param agent_class [Class] Agent to execute
|
|
292
|
+
# @param input [Hash] Input for the agent
|
|
293
|
+
# @return [Result] Agent result
|
|
294
|
+
def agent(agent_class, **input)
|
|
295
|
+
@workflow.send(:execute_agent, agent_class, input, step_name: @config.name)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Skips the current step
|
|
299
|
+
#
|
|
300
|
+
# @param reason [String] Skip reason
|
|
301
|
+
# @param default [Object] Default value to use
|
|
302
|
+
# @raise [StepSkipped]
|
|
303
|
+
def skip!(reason = nil, default: nil)
|
|
304
|
+
throw :skip_step, { skipped: true, reason: reason, default: default }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Halts the workflow successfully
|
|
308
|
+
#
|
|
309
|
+
# @param result [Hash] Final result
|
|
310
|
+
# @raise [WorkflowHalted]
|
|
311
|
+
def halt!(result = {})
|
|
312
|
+
throw :halt_workflow, { halted: true, result: result }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Fails the current step
|
|
316
|
+
#
|
|
317
|
+
# @param message [String] Error message
|
|
318
|
+
# @raise [StepFailedError]
|
|
319
|
+
def fail!(message)
|
|
320
|
+
raise StepFailedError, message
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Triggers a retry of the current step
|
|
324
|
+
#
|
|
325
|
+
# @param reason [String] Retry reason
|
|
326
|
+
# @raise [RetryStep]
|
|
327
|
+
def retry!(reason = nil)
|
|
328
|
+
raise RetryStep, reason
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Access workflow input
|
|
332
|
+
def input
|
|
333
|
+
@workflow.input
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Access previous step result
|
|
337
|
+
def previous
|
|
338
|
+
@previous_result
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Delegate missing methods to workflow (for accessing step results)
|
|
342
|
+
def method_missing(name, *args, &block)
|
|
343
|
+
if @workflow.respond_to?(name, true)
|
|
344
|
+
@workflow.send(name, *args, &block)
|
|
345
|
+
else
|
|
346
|
+
super
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def respond_to_missing?(name, include_private = false)
|
|
351
|
+
@workflow.respond_to?(name, include_private) || super
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Simple result wrapper for block steps
|
|
356
|
+
#
|
|
357
|
+
# @api private
|
|
358
|
+
class SimpleResult
|
|
359
|
+
attr_reader :content
|
|
360
|
+
|
|
361
|
+
def initialize(content:, success: true)
|
|
362
|
+
@content = content
|
|
363
|
+
@success = success
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def success?
|
|
367
|
+
@success
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def error?
|
|
371
|
+
!@success
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def input_tokens
|
|
375
|
+
0
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def output_tokens
|
|
379
|
+
0
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def total_tokens
|
|
383
|
+
0
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def cached_tokens
|
|
387
|
+
0
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def input_cost
|
|
391
|
+
0.0
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def output_cost
|
|
395
|
+
0.0
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def total_cost
|
|
399
|
+
0.0
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def to_h
|
|
403
|
+
{ content: content, success: success? }
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Error raised when a step explicitly fails
|
|
408
|
+
class StepFailedError < StandardError; end
|
|
409
|
+
|
|
410
|
+
# Error to trigger step retry
|
|
411
|
+
class RetryStep < StandardError; end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
module DSL
|
|
7
|
+
# Configuration object for a workflow wait step
|
|
8
|
+
#
|
|
9
|
+
# Holds all the configuration options for a wait step including
|
|
10
|
+
# the type (delay, until, schedule, approval), duration, conditions,
|
|
11
|
+
# timeout settings, and notification options.
|
|
12
|
+
#
|
|
13
|
+
# @example Simple delay
|
|
14
|
+
# WaitConfig.new(type: :delay, duration: 5.seconds)
|
|
15
|
+
#
|
|
16
|
+
# @example Conditional wait
|
|
17
|
+
# WaitConfig.new(
|
|
18
|
+
# type: :until,
|
|
19
|
+
# condition: -> { payment.confirmed? },
|
|
20
|
+
# poll_interval: 5.seconds,
|
|
21
|
+
# timeout: 10.minutes
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# @example Human approval
|
|
25
|
+
# WaitConfig.new(
|
|
26
|
+
# type: :approval,
|
|
27
|
+
# name: :manager_approval,
|
|
28
|
+
# notify: [:email, :slack],
|
|
29
|
+
# timeout: 24.hours
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# @api private
|
|
33
|
+
class WaitConfig
|
|
34
|
+
TYPES = %i[delay until schedule approval].freeze
|
|
35
|
+
|
|
36
|
+
attr_reader :type, :duration, :condition, :name, :options
|
|
37
|
+
|
|
38
|
+
# @param type [Symbol] Wait type (:delay, :until, :schedule, :approval)
|
|
39
|
+
# @param duration [ActiveSupport::Duration, Integer, nil] Duration for delay
|
|
40
|
+
# @param condition [Proc, nil] Condition for until/schedule waits
|
|
41
|
+
# @param name [Symbol, nil] Name for approval waits
|
|
42
|
+
# @param options [Hash] Additional options
|
|
43
|
+
def initialize(type:, duration: nil, condition: nil, name: nil, **options)
|
|
44
|
+
raise ArgumentError, "Unknown wait type: #{type}" unless TYPES.include?(type)
|
|
45
|
+
|
|
46
|
+
@type = type
|
|
47
|
+
@duration = duration
|
|
48
|
+
@condition = condition
|
|
49
|
+
@name = name
|
|
50
|
+
@options = options
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns whether this is a simple delay
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def delay?
|
|
57
|
+
type == :delay
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns whether this is a conditional wait
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def conditional?
|
|
64
|
+
type == :until
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns whether this is a scheduled wait
|
|
68
|
+
#
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def scheduled?
|
|
71
|
+
type == :schedule
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Returns whether this is an approval wait
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def approval?
|
|
78
|
+
type == :approval
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Returns the poll interval for conditional waits
|
|
82
|
+
#
|
|
83
|
+
# @return [ActiveSupport::Duration, Integer] Default: 1 second
|
|
84
|
+
def poll_interval
|
|
85
|
+
options[:poll_interval] || 1
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns the timeout for the wait
|
|
89
|
+
#
|
|
90
|
+
# @return [ActiveSupport::Duration, Integer, nil]
|
|
91
|
+
def timeout
|
|
92
|
+
options[:timeout]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Returns the action to take on timeout
|
|
96
|
+
#
|
|
97
|
+
# @return [Symbol] :fail, :continue, or :skip_next (default: :fail)
|
|
98
|
+
def on_timeout
|
|
99
|
+
options[:on_timeout] || :fail
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns the backoff multiplier for exponential backoff
|
|
103
|
+
#
|
|
104
|
+
# @return [Numeric, nil]
|
|
105
|
+
def backoff
|
|
106
|
+
options[:backoff]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the maximum poll interval when using backoff
|
|
110
|
+
#
|
|
111
|
+
# @return [ActiveSupport::Duration, Integer, nil]
|
|
112
|
+
def max_interval
|
|
113
|
+
options[:max_interval]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns whether this wait uses exponential backoff
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def exponential_backoff?
|
|
120
|
+
backoff.present?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Returns the notification channels for approval waits
|
|
124
|
+
#
|
|
125
|
+
# @return [Array<Symbol>]
|
|
126
|
+
def notify_channels
|
|
127
|
+
Array(options[:notify])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Returns the message for approval notifications
|
|
131
|
+
#
|
|
132
|
+
# @return [String, Proc, nil]
|
|
133
|
+
def message
|
|
134
|
+
options[:message]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Returns the reminder interval
|
|
138
|
+
#
|
|
139
|
+
# @return [ActiveSupport::Duration, Integer, nil]
|
|
140
|
+
def reminder_after
|
|
141
|
+
options[:reminder_after]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Returns the reminder repeat interval
|
|
145
|
+
#
|
|
146
|
+
# @return [ActiveSupport::Duration, Integer, nil]
|
|
147
|
+
def reminder_interval
|
|
148
|
+
options[:reminder_interval]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns the escalation target on timeout
|
|
152
|
+
#
|
|
153
|
+
# @return [Symbol, nil]
|
|
154
|
+
def escalate_to
|
|
155
|
+
options[:escalate_to]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns the list of approvers
|
|
159
|
+
#
|
|
160
|
+
# @return [Array<String, Symbol>]
|
|
161
|
+
def approvers
|
|
162
|
+
Array(options[:approvers])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Returns the timezone for scheduled waits
|
|
166
|
+
#
|
|
167
|
+
# @return [String, nil]
|
|
168
|
+
def timezone
|
|
169
|
+
options[:timezone]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Returns the condition for executing this wait
|
|
173
|
+
#
|
|
174
|
+
# @return [Symbol, Proc, nil]
|
|
175
|
+
def if_condition
|
|
176
|
+
options[:if]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns the negative condition for executing this wait
|
|
180
|
+
#
|
|
181
|
+
# @return [Symbol, Proc, nil]
|
|
182
|
+
def unless_condition
|
|
183
|
+
options[:unless]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Evaluates whether this wait should execute
|
|
187
|
+
#
|
|
188
|
+
# @param workflow [Workflow] The workflow instance
|
|
189
|
+
# @return [Boolean]
|
|
190
|
+
def should_execute?(workflow)
|
|
191
|
+
passes_if = if_condition.nil? || evaluate_condition(workflow, if_condition)
|
|
192
|
+
passes_unless = unless_condition.nil? || !evaluate_condition(workflow, unless_condition)
|
|
193
|
+
passes_if && passes_unless
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Returns a UI-friendly label for this wait
|
|
197
|
+
#
|
|
198
|
+
# @return [String]
|
|
199
|
+
def ui_label
|
|
200
|
+
case type
|
|
201
|
+
when :delay
|
|
202
|
+
"Wait #{format_duration(duration)}"
|
|
203
|
+
when :until
|
|
204
|
+
"Wait until condition"
|
|
205
|
+
when :schedule
|
|
206
|
+
"Wait until scheduled time"
|
|
207
|
+
when :approval
|
|
208
|
+
"Awaiting #{name || 'approval'}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Converts to hash for serialization
|
|
213
|
+
#
|
|
214
|
+
# @return [Hash]
|
|
215
|
+
def to_h
|
|
216
|
+
{
|
|
217
|
+
type: type,
|
|
218
|
+
duration: duration,
|
|
219
|
+
name: name,
|
|
220
|
+
poll_interval: poll_interval,
|
|
221
|
+
timeout: timeout,
|
|
222
|
+
on_timeout: on_timeout,
|
|
223
|
+
backoff: backoff,
|
|
224
|
+
max_interval: max_interval,
|
|
225
|
+
notify: notify_channels,
|
|
226
|
+
approvers: approvers,
|
|
227
|
+
ui_label: ui_label
|
|
228
|
+
}.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
private
|
|
232
|
+
|
|
233
|
+
def evaluate_condition(workflow, condition)
|
|
234
|
+
case condition
|
|
235
|
+
when Symbol then workflow.send(condition)
|
|
236
|
+
when Proc then workflow.instance_exec(&condition)
|
|
237
|
+
else condition
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def format_duration(dur)
|
|
242
|
+
return "unknown" unless dur
|
|
243
|
+
|
|
244
|
+
seconds = dur.respond_to?(:to_i) ? dur.to_i : dur
|
|
245
|
+
if seconds >= 3600
|
|
246
|
+
"#{seconds / 3600}h"
|
|
247
|
+
elsif seconds >= 60
|
|
248
|
+
"#{seconds / 60}m"
|
|
249
|
+
else
|
|
250
|
+
"#{seconds}s"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|