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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 65a867df3be033c50931bcb10082d4275cfd8a73aa449478f4a962de9968f568
4
+ data.tar.gz: a8eb18ab29be62d4e7b858b870da41ded99c2558e2ca379485fe3368ed04d1f0
5
+ SHA512:
6
+ metadata.gz: 25fa7216814c9ad68d1f47fa78ef860221e79627fda66a4278cb7c076c0caafdd9aef749b5ad95ef8dee44f3b79b4f6719c278e5b794c321a673f490b26b2ac7
7
+ data.tar.gz: b63c115a0884467263b86c3571d92fbd149da43759de93a789ebccd3a4f4d96aedbf49904b41c1ace3b038cf4871d2ed8592da6ae38c9eea8d5825da55d79511
@@ -0,0 +1,133 @@
1
+ # Amendment: State → Execution Type Separation
2
+
3
+ ## Problem
4
+
5
+ The original specs defined `Execution` struct with typed fields (`status`, `halt_data`, `recover_to`) but Engine/Storage used untyped `ctx[:_status]`, `ctx[:_halt]`, `ctx[:_resume_step]` instead. This created:
6
+
7
+ 1. Type safety issues (symbols become strings after JSON round-trip)
8
+ 2. Polluted `ctx` with internal state
9
+ 3. Dead code (`Execution`, `ErrorResult` structs never used)
10
+
11
+ ## Solution
12
+
13
+ - `State` = runtime (passed between executors, clean `ctx` with user variables only)
14
+ - `Execution` = persistence (typed `status`, `halt_data`, `error`, `recover_to`, `result`)
15
+ - Engine works with `State`, saves `Execution`
16
+ - Storage saves/loads `Execution`
17
+
18
+ ---
19
+
20
+ ## TODO
21
+
22
+ ### Phase 1 - Core Types
23
+
24
+ - [x] **02-TYPES: Update State struct**
25
+
26
+ - Remove `from_h` method (not needed for runtime-only struct)
27
+ - Add comment: "ctx contains user workflow variables only"
28
+
29
+ - [x] **02-TYPES: Update Execution struct**
30
+
31
+ - Add `ExecutionStatus` enum: `:pending`, `:running`, `:completed`, `:halted`, `:failed`
32
+ - Add `result` field (Any, optional) - final output when completed
33
+ - Add `error` field (String, optional) - error message when failed
34
+ - Add `to_state` method - converts to State for executor use
35
+ - Add `from_state(state, result)` class method - builds from State + ExecutionResult
36
+ - Add `from_h(hash)` class method - deserialize from storage
37
+
38
+ - [x] **02-TYPES: Remove ErrorResult**
39
+
40
+ - Delete `ErrorResult` struct (errors captured in `Execution.error`)
41
+
42
+ - [x] **02-TYPES: Update ExecutionResult**
43
+ - Use `ExecutionStatus` enum for status attribute
44
+
45
+ ### Phase 1 - Engine
46
+
47
+ - [x] **03-EXECUTION: Engine saves Execution**
48
+
49
+ - Add `save_execution(state, result)` private method
50
+ - Calls `Execution.from_state(state, result)` then `@store.save(execution)`
51
+
52
+ - [x] **03-EXECUTION: Engine.run uses save_execution**
53
+
54
+ - Initial save: `save_execution(state, ExecutionResult.new(status: :running, ...))`
55
+ - After each step: `save_execution(state, ExecutionResult.new(status: :running, ...))`
56
+ - On completion: `save_execution(state, result)` then return result
57
+ - On timeout: `save_execution(state, result)` with `:failed` status
58
+
59
+ - [x] **03-EXECUTION: Engine.resume loads Execution**
60
+
61
+ - `execution = @store.load(execution_id)`
62
+ - `state = execution.to_state`
63
+ - Resume step from `execution.recover_to || execution.current_step`
64
+
65
+ - [x] **03-EXECUTION: handle_halt saves Execution**
66
+
67
+ - Build `ExecutionResult` with `:halted` status and halt data
68
+ - Call `save_execution(state, result)`
69
+
70
+ - [x] **03-EXECUTION: Remove ctx[:_*] usage**
71
+ - No `ctx[:_status]`, `ctx[:_halt]`, `ctx[:_resume_step]`, `ctx[:_error]`
72
+ - Keep `ctx[:_last_error]` only for transient error handler access
73
+
74
+ ### Phase 2 - Storage
75
+
76
+ - [x] **01-STORAGE: Update Store interface**
77
+
78
+ - `save(execution)` - receives Execution struct
79
+ - `load(execution_id)` - returns Execution struct
80
+
81
+ - [x] **01-STORAGE: Update Redis adapter**
82
+
83
+ - `serialize_execution` / `deserialize_execution`
84
+ - `find` uses `execution.status` (not `ctx[:_status]`)
85
+
86
+ - [x] **01-STORAGE: Update ActiveRecord adapter**
87
+
88
+ - Save all Execution fields: `status`, `result`, `recover_to`, `halt_data`, `error`
89
+ - Load returns `Core::Execution.new(...)`
90
+
91
+ - [x] **01-STORAGE: Update Sequel adapter**
92
+
93
+ - Same as ActiveRecord
94
+
95
+ - [x] **01-STORAGE: Update migrations**
96
+ - Add columns: `result` (jsonb), `recover_to` (string), `halt_data` (jsonb), `error` (text)
97
+
98
+ ### Phase 2 - Runners
99
+
100
+ - [x] **02-RUNNERS: Fix Event struct**
101
+
102
+ - Change from `Struct.new(...)` to `class Event < BaseStruct`
103
+
104
+ - [x] **02-RUNNERS: Update Async runner**
105
+
106
+ - `run`: Create `Execution.new(status: :pending, ...)` not `State.new(ctx: {_status: :pending})`
107
+ - `wait`: Use `execution.status` not `state.ctx[:_status]`
108
+ - `status`: Use `execution.status` not `state.ctx[:_status]`
109
+ - `build_result`: Use `execution.result`, `execution.halt_data`, `execution.error`
110
+
111
+ - [x] **02-RUNNERS: Update Inline adapter**
112
+
113
+ - Remove manual status update (Engine handles it)
114
+
115
+ - [x] **02-RUNNERS: Update Sidekiq adapter**
116
+ - Remove manual status update (Engine handles it)
117
+
118
+ ---
119
+
120
+ ## Verification
121
+
122
+ After implementation, ensure:
123
+
124
+ 1. ✅ `ctx` only contains user workflow variables
125
+ 2. ✅ `find(status: :halted)` works without JSON parsing
126
+ 3. ✅ `execution.to_state` / `Execution.from_state` round-trip correctly
127
+ 4. ✅ All tests pass with typed Execution fields
128
+
129
+ ---
130
+
131
+ ## ✅ AMENDMENT COMPLETE
132
+
133
+ **Test Results:** 321 runs, 609 assertions, 0 failures, 0 errors, 0 skips
@@ -0,0 +1,444 @@
1
+ # Amendment 02: RubyLLM + MCP as Internal Dependencies
2
+
3
+ ## Problem
4
+
5
+ Current AI extension treats `ruby_llm` and MCP as optional/abstract:
6
+
7
+ 1. Abstract `Provider` class with pluggable implementations
8
+ 2. `Provider.current=` configuration dance
9
+ 3. MCP executor is a placeholder stub
10
+ 4. "Is gem loaded?" checks everywhere
11
+ 5. No real MCP server connectivity
12
+
13
+ This adds complexity without benefit. These gems ARE the implementation.
14
+
15
+ ## Solution
16
+
17
+ Make `ruby_llm` and `mcp` (official Anthropic MCP SDK) **required runtime dependencies**:
18
+
19
+ - Direct RubyLLM API calls in Agent executor
20
+ - Real MCP::Client for MCP executor
21
+ - Remove provider abstraction layer
22
+ - Simpler, more powerful, actually works
23
+
24
+ ---
25
+
26
+ ## Gem Details
27
+
28
+ ### ruby_llm
29
+
30
+ - **Gem:** `ruby_llm`
31
+ - **Repo:** https://github.com/crmne/ruby_llm
32
+ - **Features:** Multi-provider (OpenAI, Anthropic, Gemini, etc.), chat, streaming, tools, embeddings, moderation
33
+
34
+ ```ruby
35
+ # Chat
36
+ chat = RubyLLM.chat
37
+ response = chat.ask("Hello")
38
+
39
+ # Streaming
40
+ chat.ask("Tell me a story") { |chunk| print chunk.content }
41
+
42
+ # Tools
43
+ class MyTool < RubyLLM::Tool
44
+ description "Does something"
45
+ param :input, desc: "The input"
46
+ def execute(input:)
47
+ # return result
48
+ end
49
+ end
50
+ chat.with_tool(MyTool).ask("Use the tool")
51
+
52
+ # Moderation
53
+ RubyLLM.moderate("content to check")
54
+ ```
55
+
56
+ ### mcp (Anthropic Ruby SDK)
57
+
58
+ - **Gem:** `mcp`
59
+ - **Repo:** https://github.com/modelcontextprotocol/ruby-sdk
60
+ - **Maintainers:** Anthropic + Shopify
61
+
62
+ ```ruby
63
+ # Client usage
64
+ transport = MCP::Client::HTTP.new(url: "https://server.example.com/mcp")
65
+ client = MCP::Client.new(transport: transport)
66
+
67
+ # List tools
68
+ tools = client.tools
69
+ tools.each { |t| puts "#{t.name}: #{t.description}" }
70
+
71
+ # Call tool
72
+ result = client.call_tool(tool: tools.first, arguments: { foo: "bar" })
73
+
74
+ # With auth
75
+ transport = MCP::Client::HTTP.new(
76
+ url: "https://server.example.com/mcp",
77
+ headers: { "Authorization" => "Bearer token" }
78
+ )
79
+ ```
80
+
81
+ ---
82
+
83
+ ## TODO
84
+
85
+ ### Phase 1 - Gemspec & Dependencies
86
+
87
+ - [ ] **Add runtime dependencies to gemspec**
88
+
89
+ ```ruby
90
+ spec.add_dependency "ruby_llm", "~> 1.0"
91
+ spec.add_dependency "mcp", "~> 0.1"
92
+ spec.add_dependency "faraday", ">= 2.0" # Required by MCP HTTP client
93
+ ```
94
+
95
+ - [ ] **Update Gemfile**
96
+ - Move ruby_llm from development to runtime
97
+ - Add mcp gem
98
+
99
+ ### Phase 2 - Remove Provider Abstraction
100
+
101
+ - [ ] **Delete `lib/durable_workflow/extensions/ai/provider.rb`**
102
+
103
+ - [ ] **Delete `lib/durable_workflow/extensions/ai/providers/` directory**
104
+
105
+ - [ ] **Update `lib/durable_workflow/extensions/ai/ai.rb`**
106
+
107
+ - Remove `Provider.current` / `Provider.current=`
108
+ - Remove `AI.setup(provider:)` - no longer needed
109
+ - Add `AI.configure` for RubyLLM config (API keys, default model)
110
+
111
+ ```ruby
112
+ module AI
113
+ class << self
114
+ attr_accessor :default_model
115
+
116
+ def configure(api_key: nil, model: nil)
117
+ RubyLLM.configure { |c| c.openai_api_key = api_key } if api_key
118
+ @default_model = model || "gpt-4o-mini"
119
+ end
120
+
121
+ def chat(model: nil)
122
+ RubyLLM.chat(model: model || default_model)
123
+ end
124
+ end
125
+ end
126
+ ```
127
+
128
+ ### Phase 3 - Rewrite Agent Executor
129
+
130
+ - [ ] **Update `lib/durable_workflow/extensions/ai/executors/agent.rb`**
131
+
132
+ - Direct RubyLLM usage, no provider indirection
133
+ - Convert AgentDef tools to RubyLLM::Tool subclasses dynamically
134
+ - Support streaming via block
135
+
136
+ ```ruby
137
+ def call(state)
138
+ agent = resolve_agent(config.agent_id)
139
+ chat = AI.chat(model: agent.model)
140
+
141
+ # Add tools
142
+ agent.tools.each { |t| chat.with_tool(build_tool_class(t)) }
143
+
144
+ # Build messages
145
+ messages = build_messages(state, agent)
146
+
147
+ # Execute (with optional streaming)
148
+ response = if config.stream && @stream_handler
149
+ chat.ask(messages) { |chunk| @stream_handler.call(chunk) }
150
+ else
151
+ chat.ask(messages)
152
+ end
153
+
154
+ store_and_continue(state, response)
155
+ end
156
+
157
+ private
158
+
159
+ def build_tool_class(tool_def)
160
+ # Dynamically create RubyLLM::Tool subclass from ToolDef
161
+ Class.new(RubyLLM::Tool) do
162
+ description tool_def.description
163
+ tool_def.parameters.each { |p| param p.name, desc: p.description }
164
+
165
+ define_method(:execute) do |**args|
166
+ # Call the service method defined in ToolDef
167
+ svc = Object.const_get(tool_def.service)
168
+ svc.public_send(tool_def.method_name, **args)
169
+ end
170
+ end
171
+ end
172
+ ```
173
+
174
+ ### Phase 4 - Rewrite MCP Executor
175
+
176
+ - [ ] **Update `lib/durable_workflow/extensions/ai/executors/mcp.rb`**
177
+
178
+ - Real MCP::Client implementation
179
+ - Connection pooling for servers
180
+ - Tool discovery and invocation
181
+
182
+ ```ruby
183
+ class MCP < Base
184
+ Registry.register("mcp", self)
185
+
186
+ # Server connection cache
187
+ @clients = {}
188
+
189
+ def self.client_for(server_config)
190
+ @clients[server_config[:url]] ||= begin
191
+ transport = ::MCP::Client::HTTP.new(
192
+ url: server_config[:url],
193
+ headers: server_config[:headers] || {}
194
+ )
195
+ ::MCP::Client.new(transport: transport)
196
+ end
197
+ end
198
+
199
+ def call(state)
200
+ server_config = resolve_server(config.server)
201
+ client = self.class.client_for(server_config)
202
+
203
+ # Find tool
204
+ tool = client.tools.find { |t| t.name == config.tool }
205
+ raise ExecutionError, "MCP tool not found: #{config.tool}" unless tool
206
+
207
+ # Resolve arguments
208
+ args = resolve(state, config.arguments || {})
209
+
210
+ # Call tool
211
+ result = client.call_tool(tool: tool, arguments: args)
212
+
213
+ state = store(state, config.output, result)
214
+ continue(state, output: result)
215
+ end
216
+
217
+ private
218
+
219
+ def resolve_server(server_id)
220
+ servers = AI.data_from(workflow)[:mcp_servers] || {}
221
+ servers[server_id.to_sym] || raise(ExecutionError, "MCP server not found: #{server_id}")
222
+ end
223
+ end
224
+ ```
225
+
226
+ ### Phase 5 - Rewrite Guardrail Executor
227
+
228
+ - [ ] **Update `lib/durable_workflow/extensions/ai/executors/guardrail.rb`**
229
+
230
+ - Use `RubyLLM.moderate` directly for moderation check
231
+
232
+ ```ruby
233
+ def check_moderation(content)
234
+ result = RubyLLM.moderate(content)
235
+ GuardrailResult.new(
236
+ passed: !result.flagged?,
237
+ check_type: "moderation",
238
+ reason: result.flagged? ? "Content flagged: #{result.categories.join(', ')}" : nil
239
+ )
240
+ end
241
+ ```
242
+
243
+ ### Phase 6 - Update AI Types
244
+
245
+ - [ ] **Update `lib/durable_workflow/extensions/ai/types.rb`**
246
+
247
+ - Add MCPServerConfig type
248
+
249
+ ```ruby
250
+ class MCPServerConfig < BaseStruct
251
+ attribute :url, Types::Strict::String
252
+ attribute? :headers, Types::Hash.default({}.freeze)
253
+ attribute? :name, Types::Strict::String.optional
254
+ end
255
+ ```
256
+
257
+ - [ ] **Update MCPConfig**
258
+ ```ruby
259
+ class MCPConfig < StepConfig
260
+ attribute :server, Types::Strict::String # Server ID from workflow config
261
+ attribute :tool, Types::Strict::String # Tool name to call
262
+ attribute? :arguments, Types::Hash.default({}.freeze)
263
+ attribute? :output, Types::Coercible::Symbol.optional
264
+ end
265
+ ```
266
+
267
+ ### Phase 7 - Parser Updates
268
+
269
+ - [ ] **Update AI extension parser hooks**
270
+ - Parse `mcp_servers` section from workflow YAML
271
+ ```yaml
272
+ mcp_servers:
273
+ github:
274
+ url: "https://mcp.github.com/v1"
275
+ headers:
276
+ Authorization: "Bearer ${GITHUB_TOKEN}"
277
+ slack:
278
+ url: "https://mcp.slack.com/v1"
279
+ ```
280
+
281
+ ### Phase 8 - Update Tests
282
+
283
+ - [ ] **Delete `test/unit/extensions/ai/provider_test.rb`**
284
+
285
+ - [ ] **Delete `test/unit/extensions/ai/providers/` directory**
286
+
287
+ - [ ] **Update `test/unit/extensions/ai/executors/agent_test.rb`**
288
+
289
+ - Mock at RubyLLM level, not provider level
290
+ - Test RubyLLM::Tool dynamic class generation
291
+
292
+ - [ ] **Update `test/unit/extensions/ai/executors/mcp_test.rb`**
293
+
294
+ - Mock MCP::Client
295
+ - Test real tool discovery and invocation flow
296
+
297
+ - [ ] **Update `test/unit/extensions/ai/executors/guardrail_test.rb`**
298
+
299
+ - Mock RubyLLM.moderate for moderation tests
300
+
301
+ - [ ] **Add `test/support/mcp_mock.rb`**
302
+
303
+ - Mock MCP::Client for tests
304
+
305
+ ```ruby
306
+ class MockMCPClient
307
+ def initialize(tools: [])
308
+ @tools = tools
309
+ end
310
+
311
+ def tools
312
+ @tools
313
+ end
314
+
315
+ def call_tool(tool:, arguments:)
316
+ # Return mock result
317
+ end
318
+ end
319
+ ```
320
+
321
+ ### Phase 9 - Documentation
322
+
323
+ - [ ] **Update README usage examples**
324
+ - Show RubyLLM configuration
325
+ - Show MCP server configuration in workflow YAML
326
+ - Remove provider setup docs
327
+
328
+ ---
329
+
330
+ ## File Changes Summary
331
+
332
+ | Action | File |
333
+ | ------ | ----------------------------------------------------------- |
334
+ | DELETE | `lib/durable_workflow/extensions/ai/provider.rb` |
335
+ | DELETE | `lib/durable_workflow/extensions/ai/providers/ruby_llm.rb` |
336
+ | DELETE | `lib/durable_workflow/extensions/ai/providers/` directory |
337
+ | DELETE | `test/unit/extensions/ai/provider_test.rb` |
338
+ | MODIFY | `durable_workflow.gemspec` (add deps) |
339
+ | MODIFY | `Gemfile` (add deps) |
340
+ | MODIFY | `lib/durable_workflow/extensions/ai/ai.rb` |
341
+ | MODIFY | `lib/durable_workflow/extensions/ai/types.rb` |
342
+ | MODIFY | `lib/durable_workflow/extensions/ai/executors/agent.rb` |
343
+ | MODIFY | `lib/durable_workflow/extensions/ai/executors/mcp.rb` |
344
+ | MODIFY | `lib/durable_workflow/extensions/ai/executors/guardrail.rb` |
345
+ | MODIFY | `test/unit/extensions/ai/executors/*.rb` |
346
+ | CREATE | `test/support/mcp_mock.rb` |
347
+
348
+ ---
349
+
350
+ ## Workflow YAML Example (After)
351
+
352
+ ```yaml
353
+ id: customer_support
354
+ name: AI Customer Support
355
+ version: "1.0"
356
+
357
+ # MCP servers available to this workflow
358
+ mcp_servers:
359
+ zendesk:
360
+ url: "https://mcp.zendesk.com/v1"
361
+ headers:
362
+ Authorization: "Bearer ${ZENDESK_TOKEN}"
363
+
364
+ agents:
365
+ support_agent:
366
+ model: gpt-4o
367
+ instructions: "You are a helpful support agent..."
368
+ tools: [lookup_order, create_ticket]
369
+
370
+ tools:
371
+ lookup_order:
372
+ description: "Look up order by ID"
373
+ parameters:
374
+ - name: order_id
375
+ type: string
376
+ required: true
377
+ service: OrderService
378
+ method: find
379
+
380
+ create_ticket:
381
+ description: "Create support ticket"
382
+ parameters:
383
+ - name: subject
384
+ type: string
385
+ - name: body
386
+ type: string
387
+ service: TicketService
388
+ method: create
389
+
390
+ steps:
391
+ - id: start
392
+ type: start
393
+ next: check_input
394
+
395
+ - id: check_input
396
+ type: guardrail
397
+ content: "$input.message"
398
+ checks:
399
+ - type: prompt_injection
400
+ - type: moderation
401
+ on_fail: rejected
402
+ next: get_context
403
+
404
+ - id: get_context
405
+ type: mcp
406
+ server: zendesk
407
+ tool: get_customer_context
408
+ arguments:
409
+ customer_id: "$input.customer_id"
410
+ output: customer_context
411
+ next: respond
412
+
413
+ - id: respond
414
+ type: agent
415
+ agent_id: support_agent
416
+ prompt: "$input.message"
417
+ output: response
418
+ next: end
419
+
420
+ - id: rejected
421
+ type: assign
422
+ set:
423
+ response: "I cannot process this request."
424
+ next: end
425
+
426
+ - id: end
427
+ type: end
428
+ result: "$response"
429
+ ```
430
+
431
+ ---
432
+
433
+ ## Acceptance Criteria
434
+
435
+ 1. ✅ `ruby_llm` is runtime dependency, not optional
436
+ 2. ✅ `mcp` gem is runtime dependency
437
+ 3. ✅ No `Provider` abstraction - direct RubyLLM calls
438
+ 4. ✅ MCP executor actually connects to servers
439
+ 5. ✅ MCP executor discovers and calls tools
440
+ 6. ✅ Agent executor uses RubyLLM::Tool for tool definitions
441
+ 7. ✅ Guardrail uses RubyLLM.moderate directly
442
+ 8. ✅ Workflow YAML can define `mcp_servers` section
443
+ 9. ✅ All tests pass with mocked RubyLLM/MCP at library level
444
+ 10. ✅ Streaming works via RubyLLM blocks