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