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
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
|