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,936 @@
1
+ # 02-AI-PLUGIN: AI Extension
2
+
3
+ ## Goal
4
+
5
+ Implement the AI extension as a plugin, demonstrating the extension system. Provides: agent, guardrail, handoff, file_search, mcp step types.
6
+
7
+ ## Dependencies
8
+
9
+ - Phase 1 complete
10
+ - Phase 2 complete
11
+ - 01-EXTENSION-SYSTEM complete
12
+
13
+ ## Files to Create
14
+
15
+ ### 1. `lib/durable_workflow/extensions/ai/ai.rb` (Main loader)
16
+
17
+ ```ruby
18
+ # frozen_string_literal: true
19
+
20
+ require_relative "types"
21
+ require_relative "provider"
22
+ require_relative "providers/ruby_llm"
23
+
24
+ require_relative "executors/agent"
25
+ require_relative "executors/guardrail"
26
+ require_relative "executors/handoff"
27
+ require_relative "executors/file_search"
28
+ require_relative "executors/mcp"
29
+
30
+ module DurableWorkflow
31
+ module Extensions
32
+ module AI
33
+ class Extension < Base
34
+ self.extension_name = "ai"
35
+
36
+ def self.register_configs
37
+ Core.register_config("agent", AgentConfig)
38
+ Core.register_config("guardrail", GuardrailConfig)
39
+ Core.register_config("handoff", HandoffConfig)
40
+ Core.register_config("file_search", FileSearchConfig)
41
+ Core.register_config("mcp", MCPConfig)
42
+ end
43
+
44
+ def self.register_executors
45
+ # Executors register themselves in their files
46
+ end
47
+
48
+ def self.register_parser_hooks
49
+ Core::Parser.after_parse do |workflow|
50
+ raw = workflow.to_h
51
+ ai_data = {
52
+ agents: parse_agents(raw[:agents]),
53
+ tools: parse_tools(raw[:tools])
54
+ }
55
+ store_in(workflow, ai_data)
56
+ end
57
+ end
58
+
59
+ def self.parse_agents(agents)
60
+ return {} unless agents
61
+
62
+ agents.each_with_object({}) do |a, h|
63
+ agent = AgentDef.new(
64
+ id: a[:id],
65
+ name: a[:name],
66
+ model: a[:model],
67
+ instructions: a[:instructions],
68
+ tools: a[:tools] || [],
69
+ handoffs: parse_handoffs(a[:handoffs])
70
+ )
71
+ h[agent.id] = agent
72
+ end
73
+ end
74
+
75
+ def self.parse_handoffs(handoffs)
76
+ return [] unless handoffs
77
+
78
+ handoffs.map do |hd|
79
+ HandoffDef.new(
80
+ agent_id: hd[:agent_id],
81
+ description: hd[:description]
82
+ )
83
+ end
84
+ end
85
+
86
+ def self.parse_tools(tools)
87
+ return {} unless tools
88
+
89
+ tools.each_with_object({}) do |t, h|
90
+ tool = ToolDef.new(
91
+ id: t[:id],
92
+ description: t[:description],
93
+ parameters: parse_tool_params(t[:parameters]),
94
+ service: t[:service],
95
+ method_name: t[:method]
96
+ )
97
+ h[tool.id] = tool
98
+ end
99
+ end
100
+
101
+ def self.parse_tool_params(params)
102
+ return [] unless params
103
+
104
+ params.map do |p|
105
+ ToolParam.new(
106
+ name: p[:name],
107
+ type: p[:type] || "string",
108
+ required: p.fetch(:required, true),
109
+ description: p[:description]
110
+ )
111
+ end
112
+ end
113
+
114
+ # Helper to get agents from workflow
115
+ def self.agents(workflow)
116
+ data_from(workflow)[:agents] || {}
117
+ end
118
+
119
+ # Helper to get tools from workflow
120
+ def self.tools(workflow)
121
+ data_from(workflow)[:tools] || {}
122
+ end
123
+ end
124
+
125
+ # Setup provider (call after requiring)
126
+ def self.setup(provider: nil)
127
+ Provider.current = provider || Providers::RubyLLM.new
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ # Auto-register
134
+ DurableWorkflow::Extensions.register(:ai, DurableWorkflow::Extensions::AI::Extension)
135
+ ```
136
+
137
+ ### 2. `lib/durable_workflow/extensions/ai/types.rb`
138
+
139
+ ```ruby
140
+ # frozen_string_literal: true
141
+
142
+ module DurableWorkflow
143
+ module Extensions
144
+ module AI
145
+ # Message role enum (AI-specific, not in core)
146
+ module Types
147
+ MessageRole = DurableWorkflow::Types::Strict::String.enum("system", "user", "assistant", "tool")
148
+ end
149
+
150
+ # Handoff definition
151
+ class HandoffDef < BaseStruct
152
+ attribute :agent_id, DurableWorkflow::Types::Strict::String
153
+ attribute? :description, DurableWorkflow::Types::Strict::String.optional
154
+ end
155
+
156
+ # Agent definition
157
+ class AgentDef < BaseStruct
158
+ attribute :id, DurableWorkflow::Types::Strict::String
159
+ attribute? :name, DurableWorkflow::Types::Strict::String.optional
160
+ attribute :model, DurableWorkflow::Types::Strict::String
161
+ attribute? :instructions, DurableWorkflow::Types::Strict::String.optional
162
+ attribute :tools, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
163
+ attribute :handoffs, DurableWorkflow::Types::Strict::Array.of(HandoffDef).default([].freeze)
164
+ end
165
+
166
+ # Tool parameter
167
+ class ToolParam < BaseStruct
168
+ attribute :name, DurableWorkflow::Types::Strict::String
169
+ attribute? :type, DurableWorkflow::Types::Strict::String.optional.default("string")
170
+ attribute? :required, DurableWorkflow::Types::Strict::Bool.default(true)
171
+ attribute? :description, DurableWorkflow::Types::Strict::String.optional
172
+ end
173
+
174
+ # Tool definition
175
+ class ToolDef < BaseStruct
176
+ attribute :id, DurableWorkflow::Types::Strict::String
177
+ attribute :description, DurableWorkflow::Types::Strict::String
178
+ attribute :parameters, DurableWorkflow::Types::Strict::Array.of(ToolParam).default([].freeze)
179
+ attribute :service, DurableWorkflow::Types::Strict::String
180
+ attribute :method_name, DurableWorkflow::Types::Strict::String
181
+
182
+ def to_function_schema
183
+ {
184
+ name: id,
185
+ description:,
186
+ parameters: {
187
+ type: "object",
188
+ properties: parameters.each_with_object({}) do |p, h|
189
+ h[p.name] = { type: p.type, description: p.description }.compact
190
+ end,
191
+ required: parameters.select(&:required).map(&:name)
192
+ }
193
+ }
194
+ end
195
+ end
196
+
197
+ # Tool call from LLM
198
+ class ToolCall < BaseStruct
199
+ attribute :id, DurableWorkflow::Types::Strict::String
200
+ attribute :name, DurableWorkflow::Types::Strict::String
201
+ attribute :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
202
+ end
203
+
204
+ # Message in conversation
205
+ class Message < BaseStruct
206
+ attribute :role, Types::MessageRole
207
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
208
+ attribute? :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).optional
209
+ attribute? :tool_call_id, DurableWorkflow::Types::Strict::String.optional
210
+ attribute? :name, DurableWorkflow::Types::Strict::String.optional
211
+
212
+ def self.system(content)
213
+ new(role: "system", content:)
214
+ end
215
+
216
+ def self.user(content)
217
+ new(role: "user", content:)
218
+ end
219
+
220
+ def self.assistant(content, tool_calls: nil)
221
+ new(role: "assistant", content:, tool_calls:)
222
+ end
223
+
224
+ def self.tool(content, tool_call_id:, name: nil)
225
+ new(role: "tool", content:, tool_call_id:, name:)
226
+ end
227
+
228
+ def system? = role == "system"
229
+ def user? = role == "user"
230
+ def assistant? = role == "assistant"
231
+ def tool? = role == "tool"
232
+ def tool_calls? = tool_calls&.any?
233
+ end
234
+
235
+ # LLM response
236
+ class Response < BaseStruct
237
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
238
+ attribute :tool_calls, DurableWorkflow::Types::Strict::Array.of(ToolCall).default([].freeze)
239
+ attribute? :finish_reason, DurableWorkflow::Types::Strict::String.optional
240
+ attribute? :usage, DurableWorkflow::Types::Hash.optional
241
+
242
+ def tool_calls? = tool_calls.any?
243
+ end
244
+
245
+ # Moderation result
246
+ class ModerationResult < BaseStruct
247
+ attribute :flagged, DurableWorkflow::Types::Strict::Bool
248
+ attribute? :categories, DurableWorkflow::Types::Hash.optional
249
+ attribute? :scores, DurableWorkflow::Types::Hash.optional
250
+ end
251
+
252
+ # Guardrail check
253
+ class GuardrailCheck < BaseStruct
254
+ attribute :type, DurableWorkflow::Types::Strict::String
255
+ attribute? :pattern, DurableWorkflow::Types::Strict::String.optional
256
+ attribute? :block_on_match, DurableWorkflow::Types::Strict::Bool.default(true)
257
+ attribute? :max, DurableWorkflow::Types::Strict::Integer.optional
258
+ attribute? :min, DurableWorkflow::Types::Strict::Integer.optional
259
+ end
260
+
261
+ # Guardrail result
262
+ class GuardrailResult < BaseStruct
263
+ attribute :passed, DurableWorkflow::Types::Strict::Bool
264
+ attribute :check_type, DurableWorkflow::Types::Strict::String
265
+ attribute? :reason, DurableWorkflow::Types::Strict::String.optional
266
+ end
267
+
268
+ # AI Step Configs
269
+ class AgentConfig < Core::StepConfig
270
+ attribute :agent_id, DurableWorkflow::Types::Strict::String
271
+ attribute :prompt, DurableWorkflow::Types::Strict::String
272
+ attribute :output, DurableWorkflow::Types::Coercible::Symbol
273
+ end
274
+
275
+ class GuardrailConfig < Core::StepConfig
276
+ attribute? :content, DurableWorkflow::Types::Strict::String.optional
277
+ attribute? :input, DurableWorkflow::Types::Strict::String.optional
278
+ attribute :checks, DurableWorkflow::Types::Strict::Array.of(GuardrailCheck).default([].freeze)
279
+ attribute? :on_fail, DurableWorkflow::Types::Strict::String.optional
280
+ end
281
+
282
+ class HandoffConfig < Core::StepConfig
283
+ attribute? :to, DurableWorkflow::Types::Strict::String.optional
284
+ attribute? :from, DurableWorkflow::Types::Strict::String.optional
285
+ attribute? :reason, DurableWorkflow::Types::Strict::String.optional
286
+ end
287
+
288
+ class FileSearchConfig < Core::StepConfig
289
+ attribute :query, DurableWorkflow::Types::Strict::String
290
+ attribute :files, DurableWorkflow::Types::Strict::Array.of(DurableWorkflow::Types::Strict::String).default([].freeze)
291
+ attribute? :max_results, DurableWorkflow::Types::Strict::Integer.optional.default(10)
292
+ attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
293
+ end
294
+
295
+ class MCPConfig < Core::StepConfig
296
+ attribute :server, DurableWorkflow::Types::Strict::String
297
+ attribute :tool, DurableWorkflow::Types::Strict::String
298
+ attribute? :arguments, DurableWorkflow::Types::Hash.default({}.freeze)
299
+ attribute? :output, DurableWorkflow::Types::Coercible::Symbol.optional
300
+ end
301
+ end
302
+ end
303
+ end
304
+ ```
305
+
306
+ ### 3. `lib/durable_workflow/extensions/ai/provider.rb`
307
+
308
+ ```ruby
309
+ # frozen_string_literal: true
310
+
311
+ module DurableWorkflow
312
+ module Extensions
313
+ module AI
314
+ # Abstract LLM provider interface
315
+ class Provider
316
+ class << self
317
+ attr_accessor :current
318
+ end
319
+
320
+ def complete(messages:, model:, tools: nil, **opts)
321
+ raise NotImplementedError, "#{self.class}#complete not implemented"
322
+ end
323
+
324
+ def moderate(content)
325
+ raise NotImplementedError, "#{self.class}#moderate not implemented"
326
+ end
327
+
328
+ def stream(messages:, model:, tools: nil, **opts, &block)
329
+ response = complete(messages:, model:, tools:, **opts)
330
+ yield response.content if block_given?
331
+ response
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
337
+ ```
338
+
339
+ ### 4. `lib/durable_workflow/extensions/ai/providers/ruby_llm.rb`
340
+
341
+ ```ruby
342
+ # frozen_string_literal: true
343
+
344
+ require "json"
345
+
346
+ module DurableWorkflow
347
+ module Extensions
348
+ module AI
349
+ module Providers
350
+ class RubyLLM < Provider
351
+ def initialize(client: nil)
352
+ @client = client
353
+ end
354
+
355
+ def complete(messages:, model:, tools: nil, **opts)
356
+ raise "RubyLLM gem not loaded" unless defined?(::RubyLLM)
357
+
358
+ client = @client || ::RubyLLM
359
+
360
+ llm_messages = messages.map { |m| convert_message(m) }
361
+
362
+ request_opts = { model: }
363
+ request_opts[:tools] = tools if tools
364
+
365
+ result = client.chat(llm_messages, **request_opts)
366
+
367
+ convert_response(result)
368
+ end
369
+
370
+ def moderate(content)
371
+ ModerationResult.new(flagged: false, categories: {}, scores: {})
372
+ end
373
+
374
+ private
375
+
376
+ def convert_message(msg)
377
+ case msg.role
378
+ when "system"
379
+ { role: :system, content: msg.content }
380
+ when "user"
381
+ { role: :user, content: msg.content }
382
+ when "assistant"
383
+ result = { role: :assistant, content: msg.content }
384
+ result[:tool_calls] = msg.tool_calls.map { |tc| convert_tool_call(tc) } if msg.tool_calls?
385
+ result
386
+ when "tool"
387
+ { role: :tool, content: msg.content, tool_call_id: msg.tool_call_id }
388
+ else
389
+ { role: msg.role.to_sym, content: msg.content }
390
+ end
391
+ end
392
+
393
+ def convert_tool_call(tc)
394
+ {
395
+ id: tc.id,
396
+ type: "function",
397
+ function: {
398
+ name: tc.name,
399
+ arguments: tc.arguments.is_a?(String) ? tc.arguments : tc.arguments.to_json
400
+ }
401
+ }
402
+ end
403
+
404
+ def convert_response(result)
405
+ content = result.respond_to?(:content) ? result.content : result.to_s
406
+ tool_calls = []
407
+
408
+ if result.respond_to?(:tool_calls) && result.tool_calls&.any?
409
+ tool_calls = result.tool_calls.map do |tc|
410
+ ToolCall.new(
411
+ id: tc[:id] || tc["id"],
412
+ name: tc.dig(:function, :name) || tc.dig("function", "name"),
413
+ arguments: parse_arguments(tc.dig(:function, :arguments) || tc.dig("function", "arguments"))
414
+ )
415
+ end
416
+ end
417
+
418
+ Response.new(
419
+ content:,
420
+ tool_calls:,
421
+ finish_reason: result.respond_to?(:finish_reason) ? result.finish_reason : nil
422
+ )
423
+ end
424
+
425
+ def parse_arguments(args)
426
+ return {} if args.nil?
427
+ return args if args.is_a?(Hash)
428
+ JSON.parse(args)
429
+ rescue JSON::ParserError
430
+ { raw: args }
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
437
+ ```
438
+
439
+ ### 5. `lib/durable_workflow/extensions/ai/executors/agent.rb`
440
+
441
+ ```ruby
442
+ # frozen_string_literal: true
443
+
444
+ module DurableWorkflow
445
+ module Extensions
446
+ module AI
447
+ module Executors
448
+ class Agent < Core::Executors::Base
449
+ Core::Executors::Registry.register("agent", self)
450
+
451
+ MAX_TOOL_ITERATIONS = 10
452
+
453
+ def call(state)
454
+ @current_state = state
455
+
456
+ agent_id = config.agent_id
457
+ agent = Extension.agents(workflow(state))[agent_id]
458
+ raise ExecutionError, "Agent not found: #{agent_id}" unless agent
459
+
460
+ prompt = resolve(state, config.prompt)
461
+ messages = build_messages(agent, prompt)
462
+ tools = build_tools(state, agent)
463
+
464
+ response = run_agent_loop(state, agent, messages, tools)
465
+
466
+ state = @current_state
467
+ state = store(state, config.output, response.content)
468
+ continue(state, output: response.content)
469
+ end
470
+
471
+ private
472
+
473
+ def workflow(state)
474
+ DurableWorkflow.registry[state.workflow_id]
475
+ end
476
+
477
+ def provider
478
+ Provider.current || raise(ExecutionError, "No AI provider configured")
479
+ end
480
+
481
+ def build_messages(agent, prompt)
482
+ messages = []
483
+ messages << Message.system(agent.instructions) if agent.instructions
484
+ messages << Message.user(prompt)
485
+ messages
486
+ end
487
+
488
+ def build_tools(state, agent)
489
+ return nil if agent.tools.empty? && agent.handoffs.empty?
490
+
491
+ wf_tools = Extension.tools(workflow(state))
492
+ tool_schemas = agent.tools.map do |tool_id|
493
+ tool = wf_tools[tool_id]
494
+ next unless tool
495
+ tool.to_function_schema
496
+ end.compact
497
+
498
+ agent.handoffs.each do |handoff|
499
+ tool_schemas << {
500
+ name: "transfer_to_#{handoff.agent_id}",
501
+ description: handoff.description || "Transfer to #{handoff.agent_id}",
502
+ parameters: { type: "object", properties: {}, required: [] }
503
+ }
504
+ end
505
+
506
+ tool_schemas.empty? ? nil : tool_schemas
507
+ end
508
+
509
+ def run_agent_loop(state, agent, messages, tools)
510
+ iterations = 0
511
+
512
+ loop do
513
+ iterations += 1
514
+ raise ExecutionError, "Agent exceeded max iterations" if iterations > MAX_TOOL_ITERATIONS
515
+
516
+ response = provider.complete(
517
+ messages:,
518
+ model: agent.model,
519
+ tools:
520
+ )
521
+
522
+ return response unless response.tool_calls?
523
+
524
+ messages << Message.assistant(response.content, tool_calls: response.tool_calls)
525
+
526
+ response.tool_calls.each do |tool_call|
527
+ result = execute_tool_call(state, agent, tool_call)
528
+ messages << Message.tool(result.to_s, tool_call_id: tool_call.id, name: tool_call.name)
529
+ end
530
+ end
531
+ end
532
+
533
+ def execute_tool_call(state, agent, tool_call)
534
+ if tool_call.name.start_with?("transfer_to_")
535
+ target_agent = tool_call.name.sub("transfer_to_", "")
536
+ @current_state = @current_state.with_ctx(_handoff_to: target_agent)
537
+ return "Transferring to #{target_agent}"
538
+ end
539
+
540
+ wf_tools = Extension.tools(workflow(state))
541
+ tool = wf_tools[tool_call.name]
542
+ raise ExecutionError, "Tool not found: #{tool_call.name}" unless tool
543
+
544
+ invoke_tool(tool, tool_call.arguments)
545
+ rescue => e
546
+ "Error: #{e.message}"
547
+ end
548
+
549
+ def invoke_tool(tool, arguments)
550
+ svc = resolve_service(tool.service)
551
+ method = tool.method_name
552
+
553
+ target = svc.respond_to?(method) ? svc : svc.new
554
+ m = target.method(method)
555
+
556
+ has_kwargs = m.parameters.any? { |type, _| %i[key keyreq keyrest].include?(type) }
557
+
558
+ args = arguments.is_a?(Hash) ? arguments : {}
559
+ if has_kwargs
560
+ m.call(**args.transform_keys(&:to_sym))
561
+ elsif m.arity == 0
562
+ m.call
563
+ else
564
+ m.call(args)
565
+ end
566
+ end
567
+
568
+ def resolve_service(name)
569
+ DurableWorkflow.config&.service_resolver&.call(name) || Object.const_get(name)
570
+ end
571
+ end
572
+ end
573
+ end
574
+ end
575
+ end
576
+ ```
577
+
578
+ ### 6. `lib/durable_workflow/extensions/ai/executors/guardrail.rb`
579
+
580
+ ```ruby
581
+ # frozen_string_literal: true
582
+
583
+ module DurableWorkflow
584
+ module Extensions
585
+ module AI
586
+ module Executors
587
+ class Guardrail < Core::Executors::Base
588
+ Core::Executors::Registry.register("guardrail", self)
589
+
590
+ INJECTION_PATTERNS = [
591
+ /ignore\s+(all\s+)?previous\s+instructions/i,
592
+ /disregard\s+(all\s+)?previous/i,
593
+ /forget\s+(everything|all)/i,
594
+ /you\s+are\s+now\s+/i,
595
+ /new\s+instructions?:/i,
596
+ /system\s*:\s*/i,
597
+ /\[system\]/i,
598
+ /pretend\s+you\s+are/i,
599
+ /act\s+as\s+if/i,
600
+ /roleplay\s+as/i
601
+ ].freeze
602
+
603
+ PII_PATTERNS = [
604
+ /\b\d{3}-\d{2}-\d{4}\b/,
605
+ /\b\d{16}\b/,
606
+ /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/,
607
+ /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/,
608
+ /\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/
609
+ ].freeze
610
+
611
+ def call(state)
612
+ content = resolve(state, config.content || config.input)
613
+ checks = config.checks || []
614
+ on_fail = config.on_fail
615
+
616
+ results = checks.map { |check| run_check(check, content) }
617
+ failed = results.find { |r| !r.passed }
618
+
619
+ if failed
620
+ state = state.with_ctx(_guardrail_failure: {
621
+ check_type: failed.check_type,
622
+ reason: failed.reason
623
+ })
624
+ return on_fail ? continue(state, next_step: on_fail) : raise(ExecutionError, "Guardrail failed: #{failed.reason}")
625
+ end
626
+
627
+ continue(state)
628
+ end
629
+
630
+ private
631
+
632
+ def run_check(check, content)
633
+ case check.type
634
+ when "prompt_injection"
635
+ check_prompt_injection(content)
636
+ when "pii"
637
+ check_pii(content)
638
+ when "moderation"
639
+ check_moderation(content)
640
+ when "regex"
641
+ check_regex(content, check.pattern, check.block_on_match)
642
+ when "length"
643
+ check_length(content, check.max, check.min)
644
+ else
645
+ GuardrailResult.new(passed: true, check_type: check.type)
646
+ end
647
+ end
648
+
649
+ def check_prompt_injection(content)
650
+ detected = INJECTION_PATTERNS.any? { |pattern| content.match?(pattern) }
651
+ GuardrailResult.new(
652
+ passed: !detected,
653
+ check_type: "prompt_injection",
654
+ reason: detected ? "Potential prompt injection detected" : nil
655
+ )
656
+ end
657
+
658
+ def check_pii(content)
659
+ detected = PII_PATTERNS.any? { |pattern| content.match?(pattern) }
660
+ GuardrailResult.new(
661
+ passed: !detected,
662
+ check_type: "pii",
663
+ reason: detected ? "PII detected in content" : nil
664
+ )
665
+ end
666
+
667
+ def check_moderation(content)
668
+ provider = Provider.current
669
+ return GuardrailResult.new(passed: true, check_type: "moderation") unless provider
670
+
671
+ result = provider.moderate(content)
672
+ GuardrailResult.new(
673
+ passed: !result.flagged,
674
+ check_type: "moderation",
675
+ reason: result.flagged ? "Content flagged by moderation" : nil
676
+ )
677
+ end
678
+
679
+ def check_regex(content, pattern, block_on_match = true)
680
+ return GuardrailResult.new(passed: true, check_type: "regex") unless pattern
681
+
682
+ matches = content.match?(Regexp.new(pattern))
683
+ passed = block_on_match ? !matches : matches
684
+
685
+ GuardrailResult.new(
686
+ passed:,
687
+ check_type: "regex",
688
+ reason: passed ? nil : "Content #{block_on_match ? 'matched' : 'did not match'} pattern"
689
+ )
690
+ end
691
+
692
+ def check_length(content, max, min)
693
+ len = content.to_s.length
694
+ passed = true
695
+ reason = nil
696
+
697
+ if max && len > max
698
+ passed = false
699
+ reason = "Content exceeds max length (#{len} > #{max})"
700
+ elsif min && len < min
701
+ passed = false
702
+ reason = "Content below min length (#{len} < #{min})"
703
+ end
704
+
705
+ GuardrailResult.new(passed:, check_type: "length", reason:)
706
+ end
707
+ end
708
+ end
709
+ end
710
+ end
711
+ end
712
+ ```
713
+
714
+ ### 7. `lib/durable_workflow/extensions/ai/executors/handoff.rb`
715
+
716
+ ```ruby
717
+ # frozen_string_literal: true
718
+
719
+ module DurableWorkflow
720
+ module Extensions
721
+ module AI
722
+ module Executors
723
+ class Handoff < Core::Executors::Base
724
+ Core::Executors::Registry.register("handoff", self)
725
+
726
+ def call(state)
727
+ target_agent = config.to || state.ctx[:_handoff_to]
728
+ raise ExecutionError, "No handoff target specified" unless target_agent
729
+
730
+ workflow = DurableWorkflow.registry[state.workflow_id]
731
+ agents = Extension.agents(workflow)
732
+ raise ExecutionError, "Agent not found: #{target_agent}" unless agents.key?(target_agent)
733
+
734
+ new_ctx = state.ctx.except(:_handoff_to).merge(
735
+ _current_agent: target_agent,
736
+ _handoff_context: {
737
+ from: config.from,
738
+ to: target_agent,
739
+ reason: config.reason,
740
+ timestamp: Time.now.iso8601
741
+ }
742
+ )
743
+ state = state.with(ctx: new_ctx)
744
+
745
+ continue(state)
746
+ end
747
+ end
748
+ end
749
+ end
750
+ end
751
+ end
752
+ ```
753
+
754
+ ### 8. `lib/durable_workflow/extensions/ai/executors/file_search.rb`
755
+
756
+ ```ruby
757
+ # frozen_string_literal: true
758
+
759
+ module DurableWorkflow
760
+ module Extensions
761
+ module AI
762
+ module Executors
763
+ class FileSearch < Core::Executors::Base
764
+ Core::Executors::Registry.register("file_search", self)
765
+
766
+ def call(state)
767
+ query = resolve(state, config.query)
768
+ files = config.files || []
769
+ max_results = config.max_results || 10
770
+
771
+ results = search_files(query, files, max_results)
772
+
773
+ state = store(state, config.output, results)
774
+ continue(state, output: results)
775
+ end
776
+
777
+ private
778
+
779
+ def search_files(query, files, max_results)
780
+ # Placeholder - integrate with vector stores in production
781
+ {
782
+ query:,
783
+ results: [],
784
+ total: 0,
785
+ searched_files: files.size
786
+ }
787
+ end
788
+ end
789
+ end
790
+ end
791
+ end
792
+ end
793
+ ```
794
+
795
+ ### 9. `lib/durable_workflow/extensions/ai/executors/mcp.rb`
796
+
797
+ ```ruby
798
+ # frozen_string_literal: true
799
+
800
+ module DurableWorkflow
801
+ module Extensions
802
+ module AI
803
+ module Executors
804
+ class MCP < Core::Executors::Base
805
+ Core::Executors::Registry.register("mcp", self)
806
+
807
+ def call(state)
808
+ server = config.server
809
+ tool_name = config.tool
810
+ arguments = resolve(state, config.arguments)
811
+
812
+ result = call_mcp_tool(server, tool_name, arguments)
813
+
814
+ state = store(state, config.output, result)
815
+ continue(state, output: result)
816
+ end
817
+
818
+ private
819
+
820
+ def call_mcp_tool(server, tool_name, arguments)
821
+ # Placeholder - integrate with MCP client in production
822
+ {
823
+ server:,
824
+ tool: tool_name,
825
+ arguments:,
826
+ result: nil,
827
+ error: "MCP not configured"
828
+ }
829
+ end
830
+ end
831
+ end
832
+ end
833
+ end
834
+ end
835
+ ```
836
+
837
+ ## Usage
838
+
839
+ ```ruby
840
+ # Load core
841
+ require "durable_workflow"
842
+
843
+ # Load AI extension
844
+ require "durable_workflow/extensions/ai"
845
+
846
+ # Setup provider
847
+ DurableWorkflow::Extensions::AI.setup
848
+
849
+ # Load workflow with agents
850
+ wf = DurableWorkflow.load("ai_workflow.yml")
851
+ ```
852
+
853
+ ## Example YAML
854
+
855
+ ```yaml
856
+ id: customer-service
857
+ name: Customer Service Bot
858
+
859
+ agents:
860
+ - id: triage
861
+ model: gpt-4
862
+ instructions: "You are a triage agent. Route to appropriate specialist."
863
+ handoffs:
864
+ - agent_id: billing
865
+ description: "Transfer billing inquiries"
866
+ - agent_id: technical
867
+ description: "Transfer technical issues"
868
+
869
+ - id: billing
870
+ model: gpt-4
871
+ instructions: "You handle billing questions."
872
+ tools:
873
+ - lookup_invoice
874
+
875
+ - id: technical
876
+ model: gpt-4
877
+ instructions: "You handle technical issues."
878
+ tools:
879
+ - check_status
880
+
881
+ tools:
882
+ - id: lookup_invoice
883
+ description: "Look up an invoice by ID"
884
+ parameters:
885
+ - name: invoice_id
886
+ type: string
887
+ required: true
888
+ service: BillingService
889
+ method: lookup
890
+
891
+ - id: check_status
892
+ description: "Check system status"
893
+ parameters: []
894
+ service: StatusService
895
+ method: check
896
+
897
+ steps:
898
+ - id: start
899
+ type: start
900
+ next: guardrail
901
+
902
+ - id: guardrail
903
+ type: guardrail
904
+ input: $input.message
905
+ checks:
906
+ - type: prompt_injection
907
+ - type: pii
908
+ on_fail: reject
909
+ next: triage_agent
910
+
911
+ - id: triage_agent
912
+ type: agent
913
+ agent_id: triage
914
+ prompt: $input.message
915
+ output: response
916
+ next: end
917
+
918
+ - id: reject
919
+ type: end
920
+ result:
921
+ error: "Input rejected by guardrail"
922
+
923
+ - id: end
924
+ type: end
925
+ result:
926
+ response: $response
927
+ ```
928
+
929
+ ## Acceptance Criteria
930
+
931
+ 1. `require "durable_workflow/extensions/ai"` registers all AI executors
932
+ 2. AI step types (agent, guardrail, etc.) work in workflows
933
+ 3. Agents/tools parsed from YAML into `workflow.extensions[:ai]`
934
+ 4. Provider interface allows swapping LLM backends
935
+ 5. Guardrail checks work independently of LLM
936
+ 6. Extension doesn't pollute core types