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.
- checksums.yaml +7 -0
- data/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- 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
|