durable_workflow 0.1.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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. metadata +275 -0
@@ -0,0 +1,318 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Core
5
+ class Validator
6
+ FINISHED = '__FINISHED__'
7
+
8
+ def self.validate!(workflow)
9
+ new(workflow).validate!
10
+ end
11
+
12
+ def initialize(workflow)
13
+ @workflow = workflow
14
+ @errors = []
15
+ @step_index = workflow.steps.to_h { [_1.id, _1] }
16
+ @schemas = {} # step_id -> output schema
17
+ end
18
+
19
+ def validate!
20
+ check_unique_ids!
21
+ check_step_types!
22
+ check_references!
23
+ check_variable_reachability!
24
+ check_schema_compatibility!
25
+ check_reachability!
26
+
27
+ raise ValidationError, format_errors if @errors.any?
28
+
29
+ true
30
+ end
31
+
32
+ def valid?
33
+ validate!
34
+ rescue ValidationError
35
+ false
36
+ end
37
+
38
+ private
39
+
40
+ # ─────────────────────────────────────────────────────────────
41
+ # 0. Unique IDs
42
+ # ─────────────────────────────────────────────────────────────
43
+
44
+ def check_unique_ids!
45
+ ids = @workflow.steps.map(&:id)
46
+ dups = ids.group_by(&:itself).select { |_, v| v.size > 1 }.keys
47
+ @errors << "Duplicate step IDs: #{dups.join(', ')}" if dups.any?
48
+ end
49
+
50
+ # ─────────────────────────────────────────────────────────────
51
+ # 1. Step Types Registered
52
+ # ─────────────────────────────────────────────────────────────
53
+
54
+ def check_step_types!
55
+ @workflow.steps.each do |step|
56
+ @errors << "Unknown step type '#{step.type}' in step '#{step.id}'" unless Executors::Registry.registered?(step.type)
57
+ end
58
+ end
59
+
60
+ # ─────────────────────────────────────────────────────────────
61
+ # 2. Step References Exist
62
+ # ─────────────────────────────────────────────────────────────
63
+
64
+ def check_references!
65
+ valid_ids = @step_index.keys.to_set << FINISHED
66
+
67
+ @workflow.steps.each do |step|
68
+ check_ref(step.id, 'next', step.next_step, valid_ids)
69
+ check_ref(step.id, 'on_error', step.on_error, valid_ids)
70
+
71
+ cfg = step.config
72
+ case step.type
73
+ when 'router'
74
+ cfg.routes&.each_with_index { |r, i| check_ref(step.id, "route[#{i}]", r.target, valid_ids) }
75
+ check_ref(step.id, 'default', cfg.default, valid_ids)
76
+ when 'loop'
77
+ check_ref(step.id, 'on_exhausted', cfg.on_exhausted, valid_ids)
78
+ cfg.do&.each { |s| check_ref(step.id, 'loop.do', s.next_step, valid_ids) }
79
+ when 'parallel'
80
+ cfg.branches&.each { |s| check_ref(step.id, 'branch', s.next_step, valid_ids) }
81
+ when 'halt'
82
+ check_ref(step.id, 'resume_step', cfg.resume_step, valid_ids)
83
+ when 'approval'
84
+ check_ref(step.id, 'on_reject', cfg.on_reject, valid_ids)
85
+ check_ref(step.id, 'on_timeout', cfg.on_timeout, valid_ids) if cfg.respond_to?(:on_timeout)
86
+ when 'guardrail'
87
+ check_ref(step.id, 'on_fail', cfg.on_fail, valid_ids) if cfg.respond_to?(:on_fail)
88
+ end
89
+ end
90
+ end
91
+
92
+ def check_ref(step_id, field, target, valid_ids)
93
+ return unless target
94
+ return if valid_ids.include?(target)
95
+
96
+ @errors << "Step '#{step_id}' #{field}: references unknown step '#{target}'"
97
+ end
98
+
99
+ # ─────────────────────────────────────────────────────────────
100
+ # 3. Variable Reachability
101
+ # ─────────────────────────────────────────────────────────────
102
+
103
+ def check_variable_reachability!
104
+ # Start with workflow inputs available
105
+ initial = Set.new(@workflow.inputs.map { _1.name.to_sym })
106
+ initial << :input # $input always available
107
+ initial << :now # $now always available
108
+ initial << :history # $history always available
109
+
110
+ first = @workflow.first_step
111
+ walk_steps(first, initial, Set.new) if first
112
+ end
113
+
114
+ def walk_steps(step, available, visited)
115
+ return if step.nil?
116
+
117
+ step_key = step.is_a?(String) ? step : step.id
118
+ step = @step_index[step_key] if step.is_a?(String)
119
+ return unless step
120
+ return if visited.include?(step.id)
121
+
122
+ visited = visited.dup << step.id
123
+
124
+ # Check references in this step
125
+ check_variable_references(step, available)
126
+
127
+ # Collect output schema if present
128
+ collect_schema(step)
129
+
130
+ # Add output to available set
131
+ available = available.dup
132
+ add_step_output(step, available)
133
+
134
+ # Recurse to all possible next steps
135
+ next_steps_for(step).each do |next_id|
136
+ walk_steps(next_id, available, visited)
137
+ end
138
+ end
139
+
140
+ def check_variable_references(step, available)
141
+ refs = extract_refs(step.config)
142
+
143
+ refs.each do |ref|
144
+ root = ref.split('.').first.to_sym
145
+ next if available.include?(root)
146
+
147
+ @errors << "Step '#{step.id}': references '$#{ref}' but '#{root}' not set by preceding step"
148
+ end
149
+ end
150
+
151
+ def add_step_output(step, available)
152
+ # Handle assign step's `set` hash
153
+ step.config.set.each_key { |k| available << k.to_sym } if step.config.respond_to?(:set) && step.config.set.is_a?(Hash)
154
+
155
+ # Handle output attribute
156
+ return unless step.config.respond_to?(:output) && step.config.output
157
+
158
+ key = case step.config.output
159
+ when Symbol, String then step.config.output
160
+ when OutputConfig then step.config.output.key
161
+ when Hash then step.config.output[:key]
162
+ end
163
+
164
+ available << key.to_sym if key
165
+ end
166
+
167
+ def next_steps_for(step)
168
+ steps = []
169
+ steps << step.next_step if step.next_step
170
+ steps << step.on_error if step.on_error
171
+
172
+ case step.type
173
+ when 'router'
174
+ steps.concat(step.config.routes.map(&:target))
175
+ steps << step.config.default if step.config.default
176
+ when 'loop'
177
+ steps << step.config.on_exhausted if step.config.on_exhausted
178
+ when 'approval'
179
+ steps << step.config.on_reject if step.config.on_reject
180
+ steps << step.config.on_timeout if step.config.respond_to?(:on_timeout) && step.config.on_timeout
181
+ when 'guardrail'
182
+ steps << step.config.on_fail if step.config.respond_to?(:on_fail) && step.config.on_fail
183
+ end
184
+
185
+ steps.compact.uniq
186
+ end
187
+
188
+ def extract_refs(obj, refs = [])
189
+ case obj
190
+ when String
191
+ obj.scan(/\$([a-zA-Z_][a-zA-Z0-9_.]*)/).flatten.each { refs << _1 }
192
+ when Hash
193
+ obj.each_value { extract_refs(_1, refs) }
194
+ when Array
195
+ obj.each { extract_refs(_1, refs) }
196
+ when BaseStruct
197
+ obj.to_h.each_value { extract_refs(_1, refs) }
198
+ end
199
+ refs
200
+ end
201
+
202
+ # ─────────────────────────────────────────────────────────────
203
+ # 4. Schema Compatibility
204
+ # ─────────────────────────────────────────────────────────────
205
+
206
+ def collect_schema(step)
207
+ return unless step.config.respond_to?(:output)
208
+
209
+ output = step.config.output
210
+ schema = case output
211
+ when OutputConfig then output.schema
212
+ when Hash then output[:schema]
213
+ end
214
+
215
+ return unless schema
216
+
217
+ key = case output
218
+ when OutputConfig then output.key
219
+ when Hash then output[:key]
220
+ end
221
+
222
+ @schemas[key.to_sym] = schema if key
223
+ end
224
+
225
+ def check_schema_compatibility!
226
+ return if @schemas.empty?
227
+
228
+ @workflow.steps.each do |step|
229
+ refs = extract_refs(step.config)
230
+
231
+ refs.each do |ref|
232
+ parts = ref.split('.')
233
+ root = parts.first.to_sym
234
+
235
+ next unless @schemas.key?(root)
236
+ next if parts.size == 1 # Just $foo, not $foo.bar
237
+
238
+ validate_path_against_schema(step.id, ref, @schemas[root], parts[1..])
239
+ end
240
+ end
241
+ end
242
+
243
+ def validate_path_against_schema(step_id, full_ref, schema, path)
244
+ current = schema
245
+
246
+ path.each do |segment|
247
+ props = Utils.fetch(current, :properties)
248
+ unless props
249
+ @errors << "Step '#{step_id}': '$#{full_ref}' — schema has no properties"
250
+ return
251
+ end
252
+
253
+ prop = Utils.fetch(props, segment)
254
+ unless prop
255
+ available = props.keys.join(', ')
256
+ @errors << "Step '#{step_id}': '$#{full_ref}' — '#{segment}' not in schema (available: #{available})"
257
+ return
258
+ end
259
+
260
+ current = prop
261
+ end
262
+ end
263
+
264
+ # ─────────────────────────────────────────────────────────────
265
+ # 5. Reachability
266
+ # ─────────────────────────────────────────────────────────────
267
+
268
+ def check_reachability!
269
+ return if @workflow.steps.empty?
270
+
271
+ reachable = Set.new
272
+ queue = [@workflow.first_step.id]
273
+
274
+ while (id = queue.shift)
275
+ next if reachable.include?(id) || id == FINISHED
276
+
277
+ reachable << id
278
+ step = @step_index[id]
279
+ next unless step
280
+
281
+ queue << step.next_step if step.next_step
282
+ queue << step.on_error if step.on_error
283
+
284
+ cfg = step.config
285
+ case step.type
286
+ when 'router'
287
+ cfg.routes&.each { |r| queue << r.target }
288
+ queue << cfg.default if cfg.default
289
+ when 'loop'
290
+ cfg.do&.each { |s| queue << s.id }
291
+ queue << cfg.on_exhausted if cfg.on_exhausted
292
+ when 'parallel'
293
+ cfg.branches&.each { |s| queue << s.id }
294
+ when 'approval'
295
+ queue << cfg.on_reject if cfg.on_reject
296
+ queue << cfg.on_timeout if cfg.respond_to?(:on_timeout) && cfg.on_timeout
297
+ when 'guardrail'
298
+ queue << cfg.on_fail if cfg.respond_to?(:on_fail) && cfg.on_fail
299
+ end
300
+ end
301
+
302
+ unreachable = @workflow.step_ids - reachable.to_a
303
+ @errors << "Unreachable steps: #{unreachable.join(', ')}" if unreachable.any?
304
+ end
305
+
306
+ # ─────────────────────────────────────────────────────────────
307
+ # Error Formatting
308
+ # ─────────────────────────────────────────────────────────────
309
+
310
+ def format_errors
311
+ [
312
+ "Workflow '#{@workflow.id}' validation failed:",
313
+ *@errors.map { " - #{_1}" }
314
+ ].join("\n")
315
+ end
316
+ end
317
+ end
318
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ruby_llm'
4
+ require 'mcp'
5
+
6
+ require_relative 'types'
7
+ require_relative 'configuration'
8
+ require_relative 'tool_registry'
9
+
10
+ # MCP components
11
+ require_relative 'mcp/client'
12
+ require_relative 'mcp/adapter'
13
+ require_relative 'mcp/server'
14
+ require_relative 'mcp/rack_app'
15
+
16
+ require_relative 'executors/agent'
17
+ require_relative 'executors/guardrail'
18
+ require_relative 'executors/handoff'
19
+ require_relative 'executors/file_search'
20
+ require_relative 'executors/mcp'
21
+
22
+ module DurableWorkflow
23
+ module Extensions
24
+ module AI
25
+ class Extension < Base
26
+ self.extension_name = 'ai'
27
+
28
+ def self.register_configs
29
+ Core.register_config('agent', AgentConfig)
30
+ Core.register_config('guardrail', GuardrailConfig)
31
+ Core.register_config('handoff', HandoffConfig)
32
+ Core.register_config('file_search', FileSearchConfig)
33
+ Core.register_config('mcp', MCPConfig)
34
+ end
35
+
36
+ def self.register_executors
37
+ Core::Executors::Registry.register('agent', Executors::Agent)
38
+ Core::Executors::Registry.register('guardrail', Executors::Guardrail)
39
+ Core::Executors::Registry.register('handoff', Executors::Handoff)
40
+ Core::Executors::Registry.register('file_search', Executors::FileSearch)
41
+ Core::Executors::Registry.register('mcp', Executors::MCP)
42
+ end
43
+
44
+ def self.register_parser_hooks
45
+ Core::Parser.after_parse do |workflow, raw_yaml|
46
+ raw = raw_yaml || workflow.to_h
47
+ ai_data = {
48
+ agents: parse_agents(raw[:agents]),
49
+ tools: parse_tools(raw[:tools]),
50
+ mcp_servers: parse_mcp_servers(raw)
51
+ }
52
+
53
+ # Register tools in ToolRegistry
54
+ ai_data[:tools].each_value { |td| ToolRegistry.register_from_def(td) }
55
+
56
+ # Return workflow with AI data stored
57
+ store_in(workflow, ai_data)
58
+ end
59
+ end
60
+
61
+ def self.parse_agents(agents)
62
+ return {} unless agents
63
+
64
+ agents.each_with_object({}) do |a, h|
65
+ agent = AgentDef.new(
66
+ id: a[:id],
67
+ name: a[:name],
68
+ model: a[:model],
69
+ instructions: a[:instructions],
70
+ tools: a[:tools] || [],
71
+ handoffs: parse_handoffs(a[:handoffs])
72
+ )
73
+ h[agent.id] = agent
74
+ end
75
+ end
76
+
77
+ def self.parse_handoffs(handoffs)
78
+ return [] unless handoffs
79
+
80
+ handoffs.map do |hd|
81
+ HandoffDef.new(
82
+ agent_id: hd[:agent_id],
83
+ description: hd[:description]
84
+ )
85
+ end
86
+ end
87
+
88
+ def self.parse_tools(tools)
89
+ return {} unless tools
90
+
91
+ tools.each_with_object({}) do |t, h|
92
+ tool = ToolDef.new(
93
+ id: t[:id],
94
+ description: t[:description],
95
+ parameters: parse_tool_params(t[:parameters]),
96
+ service: t[:service],
97
+ method_name: t[:method]
98
+ )
99
+ h[tool.id] = tool
100
+ end
101
+ end
102
+
103
+ def self.parse_tool_params(params)
104
+ return [] unless params
105
+
106
+ params.map do |p|
107
+ ToolParam.new(
108
+ name: p[:name],
109
+ type: p[:type],
110
+ required: p.fetch(:required, true),
111
+ description: p[:description]
112
+ )
113
+ end
114
+ end
115
+
116
+ def self.parse_mcp_servers(raw)
117
+ return {} unless raw[:mcp_servers]
118
+
119
+ raw[:mcp_servers].transform_values do |config|
120
+ MCPServerConfig.new(
121
+ url: config[:url],
122
+ headers: config[:headers],
123
+ transport: config[:transport]&.to_sym,
124
+ command: config[:command]
125
+ )
126
+ end
127
+ end
128
+
129
+ # Helper to get agents from workflow
130
+ def self.agents(workflow)
131
+ data_from(workflow)[:agents] || {}
132
+ end
133
+
134
+ # Helper to get tools from workflow
135
+ def self.tools(workflow)
136
+ data_from(workflow)[:tools] || {}
137
+ end
138
+
139
+ # Helper to get mcp_servers from workflow
140
+ def self.mcp_servers(workflow)
141
+ data_from(workflow)[:mcp_servers] || {}
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ # Auto-register
149
+ DurableWorkflow::Extensions.register(:ai, DurableWorkflow::Extensions::AI::Extension)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ class Configuration
7
+ attr_accessor :default_model, :api_keys
8
+
9
+ def initialize
10
+ @default_model = 'gpt-4o-mini'
11
+ @api_keys = {}
12
+ end
13
+ end
14
+
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield configuration if block_given?
22
+ apply_ruby_llm_config
23
+ configuration
24
+ end
25
+
26
+ def chat(model: nil)
27
+ RubyLLM.chat(model: model || configuration.default_model)
28
+ end
29
+
30
+ private
31
+
32
+ def apply_ruby_llm_config
33
+ RubyLLM.configure do |c|
34
+ c.openai_api_key = configuration.api_keys[:openai] if configuration.api_keys[:openai]
35
+ c.anthropic_api_key = configuration.api_keys[:anthropic] if configuration.api_keys[:anthropic]
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DurableWorkflow
4
+ module Extensions
5
+ module AI
6
+ module Executors
7
+ class Agent < Core::Executors::Base
8
+ MAX_TOOL_ITERATIONS = 10
9
+
10
+ def call(state)
11
+ @current_state = state
12
+
13
+ agent_id = config.agent_id
14
+ agent = Extension.agents(workflow(state))[agent_id]
15
+ raise ExecutionError, "Agent not found: #{agent_id}" unless agent
16
+
17
+ prompt = resolve(state, config.prompt)
18
+ tool_classes = build_tool_classes(state, agent)
19
+
20
+ response = run_agent_loop(state, agent, prompt, tool_classes)
21
+
22
+ state = @current_state
23
+ content = response.content.respond_to?(:text) ? response.content.text : response.content.to_s
24
+ state = store(state, config.output, content)
25
+ continue(state, output: content)
26
+ end
27
+
28
+ private
29
+
30
+ def workflow(state)
31
+ DurableWorkflow.registry[state.workflow_id]
32
+ end
33
+
34
+ def build_chat(agent)
35
+ AI.chat(model: agent.model)
36
+ end
37
+
38
+ def build_tool_classes(_state, agent)
39
+ return [] if agent.tools.empty? && agent.handoffs.empty?
40
+
41
+ tool_classes = []
42
+
43
+ # Get RubyLLM::Tool classes from registry
44
+ agent.tools.each do |tool_id|
45
+ tool_class = ToolRegistry[tool_id]
46
+ tool_classes << tool_class if tool_class
47
+ end
48
+
49
+ # Create handoff tools
50
+ agent.handoffs.each do |handoff|
51
+ tool_classes << build_handoff_tool(handoff)
52
+ end
53
+
54
+ tool_classes
55
+ end
56
+
57
+ def build_handoff_tool(handoff)
58
+ target_agent_id = handoff.agent_id
59
+ tool_description = handoff.description || "Transfer to #{target_agent_id}"
60
+ executor_ref = self
61
+ tool_name = "transfer_to_#{target_agent_id}"
62
+
63
+ # Create named handoff tool class
64
+ class_name = "TransferTo#{target_agent_id.split('_').map(&:capitalize).join}"
65
+ return GeneratedTools.const_get(class_name) if GeneratedTools.const_defined?(class_name)
66
+
67
+ GeneratedTools.const_set(class_name, Class.new(RubyLLM::Tool) do
68
+ description tool_description
69
+
70
+ # Override name to avoid long namespace in tool name
71
+ define_method(:name) { tool_name }
72
+
73
+ define_method(:execute) do
74
+ executor_ref.instance_variable_get(:@current_state).tap do
75
+ new_state = executor_ref.instance_variable_get(:@current_state)
76
+ .with_ctx(_handoff_to: target_agent_id)
77
+ executor_ref.instance_variable_set(:@current_state, new_state)
78
+ end
79
+ "Transferring to #{target_agent_id}"
80
+ end
81
+ end)
82
+ end
83
+
84
+ def run_agent_loop(state, agent, prompt, tool_classes)
85
+ iterations = 0
86
+ chat = build_chat(agent)
87
+
88
+ # Add tools to chat
89
+ tool_classes.each { |tc| chat.with_tool(tc) }
90
+
91
+ # Build full prompt with system instructions
92
+ full_prompt = if agent.instructions
93
+ "System: #{agent.instructions}\n\nUser: #{prompt}"
94
+ else
95
+ prompt
96
+ end
97
+
98
+ # Main agent loop
99
+ loop do
100
+ iterations += 1
101
+ raise ExecutionError, "Agent exceeded max iterations (#{MAX_TOOL_ITERATIONS})" if iterations > MAX_TOOL_ITERATIONS
102
+
103
+ response = chat.ask(full_prompt)
104
+
105
+ # If no tool calls, we're done
106
+ return response unless response.tool_call?
107
+
108
+ # Execute tool calls and continue
109
+ response.tool_calls.each do |tool_call|
110
+ tool_name = begin
111
+ tool_call.name
112
+ rescue StandardError
113
+ Utils.fetch(tool_call, :name)
114
+ end
115
+ arguments = begin
116
+ tool_call.arguments
117
+ rescue StandardError
118
+ (Utils.fetch(tool_call, :arguments) || {})
119
+ end
120
+
121
+ result = execute_tool_call(state, tool_name, arguments)
122
+ full_prompt = "Tool #{tool_name} returned: #{result}"
123
+ end
124
+ end
125
+ end
126
+
127
+ def execute_tool_call(_state, tool_name, arguments)
128
+ # Check for handoff tools
129
+ if tool_name.start_with?('transfer_to_') || tool_name.match?(/^TransferTo/)
130
+ target_agent = tool_name.sub(/^transfer_to_/, '').sub(/^TransferTo/, '')
131
+ .gsub(/([A-Z])/, '_\1').downcase.sub(/^_/, '')
132
+ @current_state = @current_state.with_ctx(_handoff_to: target_agent)
133
+ return "Transferring to #{target_agent}"
134
+ end
135
+
136
+ # Execute via ToolRegistry
137
+ tool_class = ToolRegistry[tool_name]
138
+ raise ExecutionError, "Tool not found: #{tool_name}" unless tool_class
139
+
140
+ tool_instance = tool_class.new
141
+ args = arguments.is_a?(Hash) ? arguments.transform_keys(&:to_sym) : {}
142
+ tool_instance.call(**args)
143
+ rescue StandardError => e
144
+ "Error: #{e.message}"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end