igniter 0.3.1 → 0.4.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 +4 -4
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/distributed_workflow.rb +52 -0
- data/lib/igniter/compiler/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- data/lib/igniter/dsl/contract_builder.rb +42 -4
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +43 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- data/lib/igniter/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Compiler
|
|
5
|
+
module Validators
|
|
6
|
+
class RemoteValidator
|
|
7
|
+
def self.call(context)
|
|
8
|
+
new(context).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@context.runtime_nodes.each do |node|
|
|
17
|
+
next unless node.kind == :remote
|
|
18
|
+
|
|
19
|
+
validate_url!(node)
|
|
20
|
+
validate_contract_name!(node)
|
|
21
|
+
validate_dependencies!(node)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_url!(node)
|
|
28
|
+
return if node.node_url.start_with?("http://", "https://")
|
|
29
|
+
|
|
30
|
+
raise @context.validation_error(
|
|
31
|
+
node,
|
|
32
|
+
"remote :#{node.name} has invalid node: URL '#{node.node_url}'. Must start with http:// or https://"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_contract_name!(node)
|
|
37
|
+
return unless node.contract_name.strip.empty?
|
|
38
|
+
|
|
39
|
+
raise @context.validation_error(
|
|
40
|
+
node,
|
|
41
|
+
"remote :#{node.name} requires a non-empty contract: name"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_dependencies!(node)
|
|
46
|
+
node.dependencies.each do |dep_name|
|
|
47
|
+
next if @context.dependency_resolvable?(dep_name)
|
|
48
|
+
|
|
49
|
+
raise @context.validation_error(
|
|
50
|
+
node,
|
|
51
|
+
"remote :#{node.name} depends on '#{dep_name}' which is not defined in the graph"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/igniter/compiler.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "compiler/validators/outputs_validator"
|
|
|
8
8
|
require_relative "compiler/validators/dependencies_validator"
|
|
9
9
|
require_relative "compiler/validators/callable_validator"
|
|
10
10
|
require_relative "compiler/validators/type_compatibility_validator"
|
|
11
|
+
require_relative "compiler/validators/await_validator"
|
|
12
|
+
require_relative "compiler/validators/remote_validator"
|
|
11
13
|
require_relative "compiler/validation_pipeline"
|
|
12
14
|
require_relative "compiler/validator"
|
|
13
15
|
require_relative "compiler/graph_compiler"
|
data/lib/igniter/contract.rb
CHANGED
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
class Contract
|
|
5
5
|
class << self
|
|
6
|
+
def correlate_by(*keys)
|
|
7
|
+
@correlation_keys = keys.map(&:to_sym).freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def correlation_keys
|
|
11
|
+
@correlation_keys || []
|
|
12
|
+
end
|
|
13
|
+
|
|
6
14
|
def define(&block)
|
|
7
|
-
@compiled_graph = DSL::ContractBuilder.compile(
|
|
15
|
+
@compiled_graph = DSL::ContractBuilder.compile(
|
|
16
|
+
name: contract_name,
|
|
17
|
+
correlation_keys: correlation_keys,
|
|
18
|
+
&block
|
|
19
|
+
)
|
|
8
20
|
end
|
|
9
21
|
|
|
10
22
|
def run_with(runner:, max_workers: nil)
|
|
@@ -28,6 +40,45 @@ module Igniter
|
|
|
28
40
|
@compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
|
|
29
41
|
end
|
|
30
42
|
|
|
43
|
+
def start(inputs = {}, store: nil, **keyword_inputs)
|
|
44
|
+
resolved_store = store || Igniter.execution_store
|
|
45
|
+
all_inputs = inputs.merge(keyword_inputs)
|
|
46
|
+
|
|
47
|
+
instance = new(all_inputs, runner: :store, store: resolved_store)
|
|
48
|
+
instance.resolve_all
|
|
49
|
+
|
|
50
|
+
correlation = correlation_keys.each_with_object({}) do |key, hash|
|
|
51
|
+
hash[key] = all_inputs[key] || all_inputs[key.to_s]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
resolved_store.save(instance.snapshot, correlation: correlation.compact, graph: contract_name)
|
|
55
|
+
instance
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def deliver_event(event_name, correlation:, payload:, store: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
59
|
+
resolved_store = store || Igniter.execution_store
|
|
60
|
+
execution_id = resolved_store.find_by_correlation(
|
|
61
|
+
graph: contract_name,
|
|
62
|
+
correlation: correlation.transform_keys(&:to_sym)
|
|
63
|
+
)
|
|
64
|
+
unless execution_id
|
|
65
|
+
raise ResolutionError,
|
|
66
|
+
"No pending execution found for #{contract_name} with given correlation"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
instance = restore_from_store(execution_id, store: resolved_store)
|
|
70
|
+
|
|
71
|
+
await_node = instance.execution.compiled_graph.await_nodes
|
|
72
|
+
.find { |n| n.event_name == event_name.to_sym }
|
|
73
|
+
raise ResolutionError, "No await node found for event '#{event_name}' in #{contract_name}" unless await_node
|
|
74
|
+
|
|
75
|
+
instance.execution.resume(await_node.name, value: payload)
|
|
76
|
+
instance.resolve_all
|
|
77
|
+
|
|
78
|
+
resolved_store.save(instance.snapshot, correlation: correlation.transform_keys(&:to_sym), graph: contract_name)
|
|
79
|
+
instance
|
|
80
|
+
end
|
|
81
|
+
|
|
31
82
|
def restore(snapshot)
|
|
32
83
|
instance = new(
|
|
33
84
|
snapshot[:inputs] || snapshot["inputs"] || {},
|
|
@@ -159,9 +210,9 @@ module Igniter
|
|
|
159
210
|
end
|
|
160
211
|
end
|
|
161
212
|
|
|
162
|
-
attr_reader :execution, :result
|
|
213
|
+
attr_reader :execution, :result, :reactive
|
|
163
214
|
|
|
164
|
-
def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
|
|
215
|
+
def initialize(inputs = nil, runner: nil, max_workers: nil, store: nil, **keyword_inputs)
|
|
165
216
|
graph = self.class.compiled_graph
|
|
166
217
|
raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
|
|
167
218
|
|
|
@@ -175,7 +226,7 @@ module Igniter
|
|
|
175
226
|
end
|
|
176
227
|
|
|
177
228
|
execution_options = self.class.execution_options.merge(
|
|
178
|
-
{ runner: runner, max_workers: max_workers }.compact
|
|
229
|
+
{ runner: runner, max_workers: max_workers, store: store }.compact
|
|
179
230
|
)
|
|
180
231
|
execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
|
|
181
232
|
|
|
@@ -220,10 +271,6 @@ module Igniter
|
|
|
220
271
|
execution.audit.snapshot
|
|
221
272
|
end
|
|
222
273
|
|
|
223
|
-
def reactive
|
|
224
|
-
@reactive
|
|
225
|
-
end
|
|
226
|
-
|
|
227
274
|
def subscribe(subscriber = nil, &block)
|
|
228
275
|
execution.events.subscribe(subscriber, &block)
|
|
229
276
|
self
|
|
@@ -261,5 +308,9 @@ module Igniter
|
|
|
261
308
|
def failed?
|
|
262
309
|
execution.failed?
|
|
263
310
|
end
|
|
311
|
+
|
|
312
|
+
def pending?
|
|
313
|
+
execution.pending?
|
|
314
|
+
end
|
|
264
315
|
end
|
|
265
316
|
end
|
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
module DSL
|
|
5
5
|
class ContractBuilder
|
|
6
|
-
def self.compile(name: "AnonymousContract", &block)
|
|
7
|
-
new(name: name).tap { |builder| builder.instance_eval(&block) }.compile
|
|
6
|
+
def self.compile(name: "AnonymousContract", correlation_keys: [], &block)
|
|
7
|
+
new(name: name, correlation_keys: correlation_keys).tap { |builder| builder.instance_eval(&block) }.compile
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def initialize(name:)
|
|
10
|
+
def initialize(name:, correlation_keys: [])
|
|
11
11
|
@name = name
|
|
12
|
+
@correlation_keys = correlation_keys
|
|
12
13
|
@nodes = []
|
|
13
14
|
@sequence = 0
|
|
14
15
|
@scope_stack = []
|
|
@@ -228,8 +229,45 @@ module Igniter
|
|
|
228
229
|
)
|
|
229
230
|
end
|
|
230
231
|
|
|
232
|
+
def remote(name, contract:, node:, inputs:, timeout: 30, **metadata) # rubocop:disable Metrics/MethodLength
|
|
233
|
+
raise CompileError, "remote :#{name} requires inputs: Hash" unless inputs.is_a?(Hash)
|
|
234
|
+
raise CompileError, "remote :#{name} requires a contract: name" if contract.nil? || contract.to_s.strip.empty?
|
|
235
|
+
raise CompileError, "remote :#{name} requires a node: URL" if node.nil? || node.to_s.strip.empty?
|
|
236
|
+
|
|
237
|
+
add_node(
|
|
238
|
+
Model::RemoteNode.new(
|
|
239
|
+
id: next_id,
|
|
240
|
+
name: name.to_sym,
|
|
241
|
+
contract_name: contract.to_s,
|
|
242
|
+
node_url: node.to_s,
|
|
243
|
+
input_mapping: inputs,
|
|
244
|
+
timeout: timeout,
|
|
245
|
+
path: scoped_path(name),
|
|
246
|
+
metadata: with_source_location(metadata)
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def await(name, event:, **metadata)
|
|
252
|
+
add_node(
|
|
253
|
+
Model::AwaitNode.new(
|
|
254
|
+
id: next_id,
|
|
255
|
+
name: name.to_sym,
|
|
256
|
+
path: scoped_path(name),
|
|
257
|
+
event_name: event,
|
|
258
|
+
metadata: with_source_location(metadata)
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
end
|
|
262
|
+
|
|
231
263
|
def compile
|
|
232
|
-
Compiler::GraphCompiler.call(
|
|
264
|
+
Compiler::GraphCompiler.call(
|
|
265
|
+
Model::Graph.new(
|
|
266
|
+
name: @name,
|
|
267
|
+
nodes: @nodes,
|
|
268
|
+
metadata: { correlation_keys: @correlation_keys || [] }
|
|
269
|
+
)
|
|
270
|
+
)
|
|
233
271
|
end
|
|
234
272
|
|
|
235
273
|
private
|
data/lib/igniter/errors.rb
CHANGED
|
@@ -29,13 +29,18 @@ module Igniter
|
|
|
29
29
|
context[:source_location]
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def execution_id
|
|
33
|
+
context[:execution_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
private
|
|
33
37
|
|
|
34
|
-
def format_message(message, context)
|
|
38
|
+
def format_message(message, context) # rubocop:disable Metrics/AbcSize
|
|
35
39
|
details = []
|
|
36
40
|
details << "graph=#{context[:graph]}" if context[:graph]
|
|
37
41
|
details << "node=#{context[:node_name]}" if context[:node_name]
|
|
38
42
|
details << "path=#{context[:node_path]}" if context[:node_path]
|
|
43
|
+
details << "execution=#{context[:execution_id]}" if context[:execution_id]
|
|
39
44
|
details << "location=#{context[:source_location]}" if context[:source_location]
|
|
40
45
|
|
|
41
46
|
return message if details.empty?
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
class Config
|
|
6
|
+
class OllamaConfig
|
|
7
|
+
attr_accessor :base_url, :default_model, :timeout
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@base_url = "http://localhost:11434"
|
|
11
|
+
@default_model = "llama3.2"
|
|
12
|
+
@timeout = 120
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class AnthropicConfig
|
|
17
|
+
attr_accessor :api_key, :base_url, :default_model, :timeout
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@api_key = ENV["ANTHROPIC_API_KEY"]
|
|
21
|
+
@base_url = "https://api.anthropic.com"
|
|
22
|
+
@default_model = "claude-sonnet-4-6"
|
|
23
|
+
@timeout = 120
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class OpenAIConfig
|
|
28
|
+
attr_accessor :api_key, :base_url, :default_model, :timeout
|
|
29
|
+
|
|
30
|
+
def initialize
|
|
31
|
+
@api_key = ENV["OPENAI_API_KEY"]
|
|
32
|
+
@base_url = "https://api.openai.com"
|
|
33
|
+
@default_model = "gpt-4o"
|
|
34
|
+
@timeout = 120
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
PROVIDERS = %i[ollama anthropic openai].freeze
|
|
39
|
+
|
|
40
|
+
attr_accessor :default_provider
|
|
41
|
+
attr_reader :providers
|
|
42
|
+
|
|
43
|
+
def initialize
|
|
44
|
+
@default_provider = :ollama
|
|
45
|
+
@providers = {
|
|
46
|
+
ollama: OllamaConfig.new,
|
|
47
|
+
anthropic: AnthropicConfig.new,
|
|
48
|
+
openai: OpenAIConfig.new
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def ollama
|
|
53
|
+
@providers[:ollama]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def anthropic
|
|
57
|
+
@providers[:anthropic]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def openai
|
|
61
|
+
@providers[:openai]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def provider_config(name)
|
|
65
|
+
@providers.fetch(name.to_sym) { raise ArgumentError, "Unknown LLM provider: #{name}. Available: #{PROVIDERS.inspect}" }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
# Manages a conversation message history for multi-turn LLM interactions.
|
|
6
|
+
# Immutable — each operation returns a new Context.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# ctx = Igniter::LLM::Context.empty(system: "You are a helpful assistant.")
|
|
10
|
+
# ctx = ctx.append_user("What is Ruby?")
|
|
11
|
+
# ctx = ctx.append_assistant("Ruby is a dynamic language...")
|
|
12
|
+
# ctx.messages # => [{role: :system, ...}, {role: :user, ...}, {role: :assistant, ...}]
|
|
13
|
+
class Context
|
|
14
|
+
attr_reader :messages
|
|
15
|
+
|
|
16
|
+
def initialize(messages = [])
|
|
17
|
+
@messages = messages.freeze
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.empty(system: nil)
|
|
21
|
+
initial = system ? [{ role: :system, content: system }] : []
|
|
22
|
+
new(initial)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.from_h(data)
|
|
26
|
+
msgs = (data[:messages] || data["messages"] || []).map do |m|
|
|
27
|
+
{ role: (m[:role] || m["role"]).to_sym, content: (m[:content] || m["content"]).to_s }
|
|
28
|
+
end
|
|
29
|
+
new(msgs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def append_user(content)
|
|
33
|
+
append(role: :user, content: content)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def append_assistant(content)
|
|
37
|
+
append(role: :assistant, content: content)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def append_tool_result(tool_name, content)
|
|
41
|
+
append(role: :tool, content: content.to_s, name: tool_name.to_s)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def append(message)
|
|
45
|
+
self.class.new(@messages + [message.transform_keys(&:to_sym)])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def with_system(content)
|
|
49
|
+
existing = @messages.reject { |m| m[:role] == :system }
|
|
50
|
+
self.class.new([{ role: :system, content: content }] + existing)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def last_assistant_message
|
|
54
|
+
@messages.reverse.find { |m| m[:role] == :assistant }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def length
|
|
58
|
+
@messages.length
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def empty?
|
|
62
|
+
@messages.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def to_a
|
|
66
|
+
@messages.map { |m| { "role" => m[:role].to_s, "content" => m[:content].to_s } }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
{ messages: @messages }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
# Base class for LLM-powered compute nodes.
|
|
6
|
+
#
|
|
7
|
+
# Subclass and override #call(**inputs) to build prompts and get completions.
|
|
8
|
+
# Use the #complete and #chat helper methods inside #call.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# class DocumentSummarizer < Igniter::LLM::Executor
|
|
12
|
+
# provider :ollama
|
|
13
|
+
# model "llama3.2"
|
|
14
|
+
# system_prompt "You are a concise technical writer."
|
|
15
|
+
#
|
|
16
|
+
# def call(document:)
|
|
17
|
+
# complete("Summarize this in 3 bullet points:\n\n#{document}")
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# class OrderContract < Igniter::Contract
|
|
22
|
+
# define do
|
|
23
|
+
# input :document
|
|
24
|
+
# compute :summary, depends_on: :document, with: DocumentSummarizer
|
|
25
|
+
# output :summary
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
class Executor < Igniter::Executor
|
|
29
|
+
class << self
|
|
30
|
+
def provider(name = nil)
|
|
31
|
+
return @provider || Igniter::LLM.config.default_provider if name.nil?
|
|
32
|
+
|
|
33
|
+
@provider = name.to_sym
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def model(name = nil)
|
|
37
|
+
return @model || provider_config.default_model if name.nil?
|
|
38
|
+
|
|
39
|
+
@model = name
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def system_prompt(text = nil)
|
|
43
|
+
return @system_prompt if text.nil?
|
|
44
|
+
|
|
45
|
+
@system_prompt = text
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def temperature(val = nil)
|
|
49
|
+
return @temperature if val.nil?
|
|
50
|
+
|
|
51
|
+
@temperature = val
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tools(*tool_definitions)
|
|
55
|
+
return @tools || [] if tool_definitions.empty?
|
|
56
|
+
|
|
57
|
+
@tools = tool_definitions.flatten
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def inherited(subclass)
|
|
61
|
+
super
|
|
62
|
+
subclass.instance_variable_set(:@provider, @provider)
|
|
63
|
+
subclass.instance_variable_set(:@model, @model)
|
|
64
|
+
subclass.instance_variable_set(:@system_prompt, @system_prompt)
|
|
65
|
+
subclass.instance_variable_set(:@temperature, @temperature)
|
|
66
|
+
subclass.instance_variable_set(:@tools, @tools&.dup)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def provider_config
|
|
72
|
+
Igniter::LLM.provider_instance(provider).instance_of?(Class) ? nil : Igniter::LLM.config.provider_config(provider)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
Igniter::LLM.config.ollama
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :last_usage, :last_context
|
|
79
|
+
|
|
80
|
+
# Subclasses override this method. Use #complete or #chat inside.
|
|
81
|
+
def call(**_inputs)
|
|
82
|
+
raise NotImplementedError, "#{self.class.name}#call must be implemented"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
protected
|
|
86
|
+
|
|
87
|
+
# Single-turn completion. Builds a simple user message from prompt.
|
|
88
|
+
def complete(prompt, context: nil)
|
|
89
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
90
|
+
response = provider_instance.chat(
|
|
91
|
+
messages: messages,
|
|
92
|
+
model: self.class.model,
|
|
93
|
+
**completion_options
|
|
94
|
+
)
|
|
95
|
+
@last_usage = provider_instance.last_usage
|
|
96
|
+
@last_context = track_context(context, prompt, response[:content])
|
|
97
|
+
response[:content]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Multi-turn chat with a Context object or raw messages array.
|
|
101
|
+
def chat(context:)
|
|
102
|
+
messages = context.is_a?(Context) ? context.to_a : Array(context)
|
|
103
|
+
response = provider_instance.chat(
|
|
104
|
+
messages: messages,
|
|
105
|
+
model: self.class.model,
|
|
106
|
+
**completion_options
|
|
107
|
+
)
|
|
108
|
+
@last_usage = provider_instance.last_usage
|
|
109
|
+
response[:content]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Tool-use call. Returns DeferredResult if the LLM wants to call a tool.
|
|
113
|
+
def complete_with_tools(prompt, context: nil) # rubocop:disable Metrics/MethodLength
|
|
114
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
115
|
+
response = provider_instance.chat(
|
|
116
|
+
messages: messages,
|
|
117
|
+
model: self.class.model,
|
|
118
|
+
tools: self.class.tools,
|
|
119
|
+
**completion_options
|
|
120
|
+
)
|
|
121
|
+
@last_usage = provider_instance.last_usage
|
|
122
|
+
|
|
123
|
+
if response[:tool_calls].any?
|
|
124
|
+
defer(payload: { tool_calls: response[:tool_calls], messages: messages })
|
|
125
|
+
else
|
|
126
|
+
response[:content]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def provider_instance
|
|
133
|
+
@provider_instance ||= Igniter::LLM.provider_instance(self.class.provider)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def build_messages(prompt:, context: nil)
|
|
137
|
+
base = []
|
|
138
|
+
base << { role: "system", content: self.class.system_prompt } if self.class.system_prompt
|
|
139
|
+
|
|
140
|
+
if context.is_a?(Context)
|
|
141
|
+
base + context.to_a + [{ role: "user", content: prompt }]
|
|
142
|
+
else
|
|
143
|
+
base + [{ role: "user", content: prompt }]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def completion_options
|
|
148
|
+
opts = {}
|
|
149
|
+
opts[:temperature] = self.class.temperature if self.class.temperature
|
|
150
|
+
opts
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def track_context(existing, user_prompt, assistant_reply)
|
|
154
|
+
ctx = existing.is_a?(Context) ? existing : Context.empty(system: self.class.system_prompt)
|
|
155
|
+
ctx.append_user(user_prompt).append_assistant(assistant_reply)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|