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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
  7. data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
  9. data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
  10. data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
  12. data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
  13. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
  14. data/app/models/ruby_llm/agents/execution.rb +50 -14
  15. data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
  16. data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
  17. data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
  18. data/app/models/ruby_llm/agents/tenant.rb +146 -0
  19. data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
  20. data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
  21. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  22. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  23. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  24. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  25. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  26. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  27. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  28. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  30. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  31. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  32. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  33. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  34. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  35. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  36. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  37. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  38. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  39. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  40. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  41. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  42. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  43. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  44. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  45. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  46. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  47. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  48. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  49. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  50. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  51. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  52. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  53. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  54. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  55. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  56. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  57. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  58. data/config/routes.rb +1 -1
  59. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  60. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  61. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  62. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  65. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  66. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  67. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  68. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  69. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  70. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  71. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
  72. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  73. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  74. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
  75. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  76. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  77. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  78. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  79. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  80. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  81. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  82. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  83. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  84. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  85. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  86. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  87. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  88. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  89. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  90. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
  91. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
  92. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  93. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  94. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  95. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  96. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  97. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  98. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  99. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  100. data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
  101. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  102. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  103. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  104. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  105. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  106. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  107. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  108. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  109. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  110. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  111. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  112. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  113. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  114. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  115. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  116. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  117. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  118. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
  119. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  120. data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
  121. data/lib/ruby_llm/agents/core/version.rb +1 -1
  122. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  123. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
  124. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  125. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  126. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  127. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  128. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  129. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  130. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  131. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  132. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  133. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  134. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  135. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  136. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  137. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  138. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  139. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  140. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  141. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  142. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  143. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  144. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  145. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  146. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  147. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  148. metadata +43 -6
  149. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  150. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  151. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  152. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Helper methods for scheduling wait_until time calculations
8
+ #
9
+ # These methods can be used within workflow definitions to create
10
+ # dynamic scheduling logic for wait_until time: expressions.
11
+ #
12
+ # @example Using in a workflow
13
+ # class ReportWorkflow < RubyLLM::Agents::Workflow
14
+ # include ScheduleHelpers
15
+ #
16
+ # step :generate, ReportAgent
17
+ # wait_until time: -> { next_weekday_at(9, 0) }
18
+ # step :send, EmailAgent
19
+ # end
20
+ #
21
+ # @api public
22
+ module ScheduleHelpers
23
+ # Returns the next occurrence of a weekday (Mon-Fri) at the specified time
24
+ #
25
+ # @param hour [Integer] Hour (0-23)
26
+ # @param minute [Integer] Minute (0-59)
27
+ # @param timezone [String, nil] Timezone name (uses system timezone if nil)
28
+ # @return [Time]
29
+ def next_weekday_at(hour, minute, timezone: nil)
30
+ now = current_time(timezone)
31
+ target = build_time(now, hour, minute, timezone)
32
+
33
+ # If target time has passed today or it's a weekend, find next weekday
34
+ if target <= now || weekend?(target)
35
+ target = advance_to_next_weekday(target)
36
+ end
37
+
38
+ target
39
+ end
40
+
41
+ # Returns the start of the next hour
42
+ #
43
+ # @param timezone [String, nil] Timezone name
44
+ # @return [Time]
45
+ def next_hour(timezone: nil)
46
+ now = current_time(timezone)
47
+ Time.new(now.year, now.month, now.day, now.hour + 1, 0, 0, now.utc_offset)
48
+ end
49
+
50
+ # Returns tomorrow at the specified time
51
+ #
52
+ # @param hour [Integer] Hour (0-23)
53
+ # @param minute [Integer] Minute (0-59)
54
+ # @param timezone [String, nil] Timezone name
55
+ # @return [Time]
56
+ def tomorrow_at(hour, minute, timezone: nil)
57
+ now = current_time(timezone)
58
+ tomorrow = now + 86_400 # Add one day in seconds
59
+ build_time(tomorrow, hour, minute, timezone)
60
+ end
61
+
62
+ # Returns the next available time within business hours
63
+ #
64
+ # Business hours default to Mon-Fri, 9am-5pm.
65
+ #
66
+ # @param start_hour [Integer] Business day start hour (default: 9)
67
+ # @param end_hour [Integer] Business day end hour (default: 17)
68
+ # @param timezone [String, nil] Timezone name
69
+ # @return [Time]
70
+ def in_business_hours(start_hour: 9, end_hour: 17, timezone: nil)
71
+ now = current_time(timezone)
72
+
73
+ # If current time is within business hours, return now
74
+ if within_business_hours?(now, start_hour, end_hour)
75
+ return now
76
+ end
77
+
78
+ # If before business hours today and it's a weekday
79
+ if now.hour < start_hour && !weekend?(now)
80
+ return build_time(now, start_hour, 0, timezone)
81
+ end
82
+
83
+ # Find next business day
84
+ target = next_weekday_at(start_hour, 0, timezone: timezone)
85
+ target
86
+ end
87
+
88
+ # Returns a specific day of the week at the specified time
89
+ #
90
+ # @param day [Symbol] Day name (:monday, :tuesday, etc.)
91
+ # @param hour [Integer] Hour (0-23)
92
+ # @param minute [Integer] Minute (0-59)
93
+ # @param timezone [String, nil] Timezone name
94
+ # @return [Time]
95
+ def next_day_at(day, hour, minute, timezone: nil)
96
+ days = %i[sunday monday tuesday wednesday thursday friday saturday]
97
+ target_wday = days.index(day.to_sym)
98
+ raise ArgumentError, "Unknown day: #{day}" unless target_wday
99
+
100
+ now = current_time(timezone)
101
+ current_wday = now.wday
102
+ days_ahead = (target_wday - current_wday) % 7
103
+
104
+ # If it's the same day but time has passed, add a week
105
+ if days_ahead == 0
106
+ target = build_time(now, hour, minute, timezone)
107
+ days_ahead = 7 if target <= now
108
+ end
109
+
110
+ future = now + (days_ahead * 86_400)
111
+ build_time(future, hour, minute, timezone)
112
+ end
113
+
114
+ # Returns time at the start of the next month
115
+ #
116
+ # @param day [Integer] Day of month (default: 1)
117
+ # @param hour [Integer] Hour (default: 0)
118
+ # @param minute [Integer] Minute (default: 0)
119
+ # @param timezone [String, nil] Timezone name
120
+ # @return [Time]
121
+ def next_month_at(day: 1, hour: 0, minute: 0, timezone: nil)
122
+ now = current_time(timezone)
123
+ year = now.year
124
+ month = now.month + 1
125
+
126
+ if month > 12
127
+ month = 1
128
+ year += 1
129
+ end
130
+
131
+ Time.new(year, month, day, hour, minute, 0, now.utc_offset)
132
+ end
133
+
134
+ # Returns a time offset from now
135
+ #
136
+ # @param seconds [Integer, Float] Seconds to add
137
+ # @param timezone [String, nil] Timezone name
138
+ # @return [Time]
139
+ def from_now(seconds, timezone: nil)
140
+ current_time(timezone) + seconds
141
+ end
142
+
143
+ private
144
+
145
+ def current_time(timezone)
146
+ if timezone && defined?(ActiveSupport::TimeZone)
147
+ ActiveSupport::TimeZone[timezone]&.now || Time.now
148
+ else
149
+ Time.now
150
+ end
151
+ end
152
+
153
+ def build_time(base, hour, minute, timezone)
154
+ if timezone && defined?(ActiveSupport::TimeZone)
155
+ zone = ActiveSupport::TimeZone[timezone]
156
+ if zone
157
+ zone.local(base.year, base.month, base.day, hour, minute, 0)
158
+ else
159
+ Time.new(base.year, base.month, base.day, hour, minute, 0, base.utc_offset)
160
+ end
161
+ else
162
+ Time.new(base.year, base.month, base.day, hour, minute, 0, base.utc_offset)
163
+ end
164
+ end
165
+
166
+ def weekend?(time)
167
+ time.saturday? || time.sunday?
168
+ end
169
+
170
+ def advance_to_next_weekday(time)
171
+ loop do
172
+ time += 86_400 # Add one day
173
+ break unless weekend?(time)
174
+ end
175
+ time
176
+ end
177
+
178
+ def within_business_hours?(time, start_hour, end_hour)
179
+ !weekend?(time) &&
180
+ time.hour >= start_hour &&
181
+ time.hour < end_hour
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Configuration object for a workflow step
8
+ #
9
+ # Holds all the configuration options for a step including the agent,
10
+ # input mapping, conditions, retry settings, error handling, and metadata.
11
+ #
12
+ # @example Basic step config
13
+ # StepConfig.new(name: :validate, agent: ValidatorAgent)
14
+ #
15
+ # @example Full configuration
16
+ # StepConfig.new(
17
+ # name: :process,
18
+ # agent: ProcessorAgent,
19
+ # description: "Process the order",
20
+ # timeout: 30,
21
+ # retry_config: { max: 3, on: [Timeout::Error] },
22
+ # critical: true
23
+ # )
24
+ #
25
+ # @api private
26
+ class StepConfig
27
+ attr_reader :name, :agent, :description, :options, :block
28
+
29
+ # @param name [Symbol] Step identifier
30
+ # @param agent [Class, nil] Agent class to execute
31
+ # @param description [String, nil] Human-readable description
32
+ # @param options [Hash] Step configuration options
33
+ # @param block [Proc, nil] Block for routing or custom logic
34
+ def initialize(name:, agent: nil, description: nil, options: {}, block: nil)
35
+ @name = name
36
+ @agent = agent
37
+ @options = normalize_options(options)
38
+ # description can come from direct param or from :desc option
39
+ @description = description || @options[:description]
40
+ @block = block
41
+ end
42
+
43
+ # Returns whether this step uses routing
44
+ #
45
+ # @return [Boolean]
46
+ def routing?
47
+ options[:on].present? && block.present?
48
+ end
49
+
50
+ # Returns whether this step has a custom block
51
+ #
52
+ # @return [Boolean]
53
+ def custom_block?
54
+ block.present? && !routing?
55
+ end
56
+
57
+ # Returns whether this step is optional (continues on failure)
58
+ #
59
+ # @return [Boolean]
60
+ def optional?
61
+ options[:optional] == true
62
+ end
63
+
64
+ # Returns whether this step executes a sub-workflow
65
+ #
66
+ # @return [Boolean]
67
+ def workflow?
68
+ return false unless agent.present?
69
+ return false unless agent.is_a?(Class)
70
+
71
+ # agent < Workflow returns nil if agent is not a subclass
72
+ (agent < RubyLLM::Agents::Workflow) == true
73
+ rescue TypeError, ArgumentError
74
+ # agent < Workflow raises TypeError/ArgumentError if agent is not a valid Class
75
+ false
76
+ end
77
+
78
+ # Returns whether this step uses iteration
79
+ #
80
+ # @return [Boolean]
81
+ def iteration?
82
+ options[:each].present?
83
+ end
84
+
85
+ # Returns the source for iteration items
86
+ #
87
+ # @return [Proc, nil]
88
+ def each_source
89
+ options[:each]
90
+ end
91
+
92
+ # Returns the concurrency level for iteration
93
+ #
94
+ # @return [Integer, nil]
95
+ def iteration_concurrency
96
+ options[:concurrency]
97
+ end
98
+
99
+ # Returns whether iteration should fail fast on first error
100
+ #
101
+ # @return [Boolean]
102
+ def iteration_fail_fast?
103
+ options[:fail_fast] == true
104
+ end
105
+
106
+ # Returns whether iteration should continue on individual item errors
107
+ #
108
+ # @return [Boolean]
109
+ def continue_on_error?
110
+ options[:continue_on_error] == true
111
+ end
112
+
113
+ # Returns whether this step is critical (fails workflow on error)
114
+ #
115
+ # @return [Boolean]
116
+ def critical?
117
+ options[:critical] != false && !optional?
118
+ end
119
+
120
+ # Returns the timeout for this step
121
+ #
122
+ # @return [Integer, nil] Timeout in seconds
123
+ def timeout
124
+ value = options[:timeout]
125
+ return nil unless value
126
+ value.respond_to?(:to_i) ? value.to_i : value
127
+ end
128
+
129
+ # Returns retry configuration
130
+ #
131
+ # @return [Hash] Retry settings
132
+ def retry_config
133
+ @retry_config ||= normalize_retry_config
134
+ end
135
+
136
+ # Returns the fallback agent(s)
137
+ #
138
+ # @return [Array<Class>] Fallback agents
139
+ def fallbacks
140
+ @fallbacks ||= Array(options[:fallback]).compact
141
+ end
142
+
143
+ # Returns the condition for step execution
144
+ #
145
+ # @return [Symbol, Proc, nil]
146
+ def if_condition
147
+ options[:if]
148
+ end
149
+
150
+ # Returns the negative condition for step execution
151
+ #
152
+ # @return [Symbol, Proc, nil]
153
+ def unless_condition
154
+ options[:unless]
155
+ end
156
+
157
+ # Returns the input mapper (lambda or pick config)
158
+ #
159
+ # @return [Proc, nil]
160
+ def input_mapper
161
+ options[:input]
162
+ end
163
+
164
+ # Returns fields to pick from previous step
165
+ #
166
+ # @return [Array<Symbol>, nil]
167
+ def pick_fields
168
+ options[:pick]
169
+ end
170
+
171
+ # Returns the source step for pick operation
172
+ #
173
+ # @return [Symbol, nil]
174
+ def pick_from
175
+ options[:from]
176
+ end
177
+
178
+ # Returns the default value when step is skipped or fails (optional)
179
+ #
180
+ # @return [Object, nil]
181
+ def default_value
182
+ options[:default]
183
+ end
184
+
185
+ # Returns the error handler
186
+ #
187
+ # @return [Symbol, Proc, nil]
188
+ def error_handler
189
+ options[:on_error]
190
+ end
191
+
192
+ # Returns UI-friendly label
193
+ #
194
+ # @return [String, nil]
195
+ def ui_label
196
+ options[:ui_label]
197
+ end
198
+
199
+ # Returns tags for the step
200
+ #
201
+ # @return [Array<Symbol>]
202
+ def tags
203
+ Array(options[:tags])
204
+ end
205
+
206
+ # Returns the throttle duration for this step
207
+ #
208
+ # @return [Integer, Float, nil] Minimum seconds between executions
209
+ def throttle
210
+ value = options[:throttle]
211
+ return nil unless value
212
+
213
+ value.respond_to?(:to_f) ? value.to_f : value
214
+ end
215
+
216
+ # Returns the rate limit configuration for this step
217
+ #
218
+ # @return [Hash, nil] Rate limit config with :calls and :per keys
219
+ def rate_limit
220
+ options[:rate_limit]
221
+ end
222
+
223
+ # Returns whether this step has throttling enabled
224
+ #
225
+ # @return [Boolean]
226
+ def throttled?
227
+ throttle.present? || rate_limit.present?
228
+ end
229
+
230
+ # Resolves the input for this step
231
+ #
232
+ # @param workflow [Workflow] The workflow instance
233
+ # @param previous_result [Result, nil] Previous step result
234
+ # @return [Hash] Input for the agent
235
+ def resolve_input(workflow, previous_result)
236
+ if input_mapper
237
+ workflow.instance_exec(&input_mapper)
238
+ elsif pick_fields
239
+ source = pick_from ? workflow.step_result(pick_from) : previous_result
240
+ source_hash = extract_content_hash(source)
241
+ source_hash.slice(*pick_fields)
242
+ else
243
+ # Default: merge original input with previous step output
244
+ base = workflow.input.to_h
245
+ previous_hash = extract_content_hash(previous_result)
246
+ base.merge(previous_hash)
247
+ end
248
+ end
249
+
250
+ # Resolves the route for routing steps
251
+ #
252
+ # @param workflow [Workflow] The workflow instance
253
+ # @return [Hash] Route configuration with :agent and :options
254
+ def resolve_route(workflow)
255
+ raise "Not a routing step" unless routing?
256
+
257
+ value = workflow.instance_exec(&options[:on])
258
+ builder = RouteBuilder.new
259
+ block.call(builder)
260
+ builder.resolve(value)
261
+ end
262
+
263
+ # Evaluates whether the step should execute
264
+ #
265
+ # @param workflow [Workflow] The workflow instance
266
+ # @return [Boolean]
267
+ def should_execute?(workflow)
268
+ passes_if = if_condition.nil? || evaluate_condition(workflow, if_condition)
269
+ passes_unless = unless_condition.nil? || !evaluate_condition(workflow, unless_condition)
270
+ passes_if && passes_unless
271
+ end
272
+
273
+ # Converts to hash for serialization
274
+ #
275
+ # @return [Hash]
276
+ def to_h
277
+ {
278
+ name: name,
279
+ agent: agent&.name,
280
+ description: description,
281
+ timeout: timeout,
282
+ optional: optional?,
283
+ critical: critical?,
284
+ retry_config: retry_config,
285
+ fallbacks: fallbacks.map(&:name),
286
+ tags: tags,
287
+ ui_label: ui_label,
288
+ workflow: workflow?,
289
+ iteration: iteration?,
290
+ iteration_concurrency: iteration_concurrency,
291
+ iteration_fail_fast: iteration_fail_fast?,
292
+ throttle: throttle,
293
+ rate_limit: rate_limit
294
+ }.compact
295
+ end
296
+
297
+ private
298
+
299
+ def normalize_options(opts)
300
+ # Handle desc as alias for description
301
+ opts[:description] ||= opts.delete(:desc)
302
+ opts
303
+ end
304
+
305
+ def normalize_retry_config
306
+ retry_opt = options[:retry]
307
+ on_opt = options[:on]
308
+
309
+ case retry_opt
310
+ when Integer
311
+ {
312
+ max: retry_opt,
313
+ on: normalize_error_classes(on_opt) || [StandardError],
314
+ backoff: :none,
315
+ delay: 1
316
+ }
317
+ when Hash
318
+ {
319
+ max: retry_opt[:max] || 3,
320
+ on: normalize_error_classes(retry_opt[:on] || on_opt) || [StandardError],
321
+ backoff: retry_opt[:backoff] || :none,
322
+ delay: retry_opt[:delay] || 1
323
+ }
324
+ else
325
+ { max: 0, on: [], backoff: :none, delay: 1 }
326
+ end
327
+ end
328
+
329
+ def normalize_error_classes(classes)
330
+ return nil if classes.nil?
331
+ Array(classes)
332
+ end
333
+
334
+ def evaluate_condition(workflow, condition)
335
+ case condition
336
+ when Symbol then workflow.send(condition)
337
+ when Proc then workflow.instance_exec(&condition)
338
+ else condition
339
+ end
340
+ end
341
+
342
+ def extract_content_hash(result)
343
+ return {} if result.nil?
344
+
345
+ content = result.respond_to?(:content) ? result.content : result
346
+ content.is_a?(Hash) ? content : {}
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
352
+ end