igniter 0.4.3 → 0.5.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/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +128 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Incremental
|
|
5
|
+
# Structured result of a resolve_incrementally call.
|
|
6
|
+
#
|
|
7
|
+
# Attributes:
|
|
8
|
+
# changed_nodes — node names whose value_version increased (value actually changed)
|
|
9
|
+
# skipped_nodes — node names that were stale but memoized (deps unchanged, compute skipped)
|
|
10
|
+
# backdated_nodes — node names that recomputed but produced the same value
|
|
11
|
+
# changed_outputs — Hash{ Symbol => { from: old_value, to: new_value } }
|
|
12
|
+
# recomputed_count — total number of compute calls actually executed
|
|
13
|
+
class Result
|
|
14
|
+
attr_reader :changed_nodes, :skipped_nodes, :backdated_nodes,
|
|
15
|
+
:changed_outputs, :recomputed_count
|
|
16
|
+
|
|
17
|
+
def initialize(changed_nodes:, skipped_nodes:, backdated_nodes:,
|
|
18
|
+
changed_outputs:, recomputed_count:)
|
|
19
|
+
@changed_nodes = changed_nodes.freeze
|
|
20
|
+
@skipped_nodes = skipped_nodes.freeze
|
|
21
|
+
@backdated_nodes = backdated_nodes.freeze
|
|
22
|
+
@changed_outputs = changed_outputs.freeze
|
|
23
|
+
@recomputed_count = recomputed_count
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# True when at least one output value changed.
|
|
28
|
+
def outputs_changed?
|
|
29
|
+
changed_outputs.any?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# True when every stale node was memoized — nothing actually ran.
|
|
33
|
+
def fully_memoized?
|
|
34
|
+
recomputed_count.zero?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One-line summary for logging.
|
|
38
|
+
def summary # rubocop:disable Metrics/AbcSize
|
|
39
|
+
parts = []
|
|
40
|
+
parts << "#{changed_nodes.size} node(s) changed" if changed_nodes.any?
|
|
41
|
+
parts << "#{skipped_nodes.size} skipped (memoized)" if skipped_nodes.any?
|
|
42
|
+
parts << "#{backdated_nodes.size} backdated (same value)" if backdated_nodes.any?
|
|
43
|
+
parts << "#{recomputed_count} recomputed"
|
|
44
|
+
parts.join(", ")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Human-readable ASCII report.
|
|
48
|
+
def explain
|
|
49
|
+
Formatter.format(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
alias to_s explain
|
|
53
|
+
|
|
54
|
+
def to_h # rubocop:disable Metrics/MethodLength
|
|
55
|
+
{
|
|
56
|
+
changed_nodes: changed_nodes,
|
|
57
|
+
skipped_nodes: skipped_nodes,
|
|
58
|
+
backdated_nodes: backdated_nodes,
|
|
59
|
+
changed_outputs: changed_outputs.transform_values do |diff|
|
|
60
|
+
{ from: diff[:from], to: diff[:to] }
|
|
61
|
+
end,
|
|
62
|
+
recomputed_count: recomputed_count,
|
|
63
|
+
outputs_changed: outputs_changed?,
|
|
64
|
+
fully_memoized: fully_memoized?
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Incremental
|
|
5
|
+
# Subscribes to execution events and records which nodes were changed,
|
|
6
|
+
# skipped (memoized), or backdated during an incremental resolve pass.
|
|
7
|
+
#
|
|
8
|
+
# Takes a pre-snapshot of value_versions before the resolve pass and compares
|
|
9
|
+
# after to detect what changed, rather than relying on event payloads alone.
|
|
10
|
+
class Tracker
|
|
11
|
+
def initialize(execution)
|
|
12
|
+
@execution = execution
|
|
13
|
+
@skipped_nodes = []
|
|
14
|
+
@backdated_nodes = []
|
|
15
|
+
@recomputed_nodes = []
|
|
16
|
+
@pre_node_vv = {}
|
|
17
|
+
@pre_output_values = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start!
|
|
21
|
+
snapshot_pre_state!
|
|
22
|
+
@execution.events.subscribe(self)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Called by Events::Bus for every event.
|
|
26
|
+
def call(event)
|
|
27
|
+
case event.type
|
|
28
|
+
when :node_skipped
|
|
29
|
+
@skipped_nodes << event.node_name
|
|
30
|
+
when :node_backdated
|
|
31
|
+
@backdated_nodes << event.node_name
|
|
32
|
+
when :node_succeeded
|
|
33
|
+
kind = fetch_node_kind(event.node_name)
|
|
34
|
+
@recomputed_nodes << event.node_name if %i[compute effect].include?(kind)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def build_result # rubocop:disable Metrics/MethodLength
|
|
39
|
+
# Deduplicate (events may fire multiple times across execution passes)
|
|
40
|
+
skipped = @skipped_nodes.uniq
|
|
41
|
+
backdated = @backdated_nodes.uniq
|
|
42
|
+
# recomputed = node_succeeded minus skipped (skipped also fires node_succeeded)
|
|
43
|
+
recomputed = @recomputed_nodes.uniq - skipped
|
|
44
|
+
|
|
45
|
+
changed = detect_changed_nodes
|
|
46
|
+
changed_outputs = detect_changed_outputs
|
|
47
|
+
|
|
48
|
+
Result.new(
|
|
49
|
+
changed_nodes: changed,
|
|
50
|
+
skipped_nodes: skipped,
|
|
51
|
+
backdated_nodes: backdated,
|
|
52
|
+
changed_outputs: changed_outputs,
|
|
53
|
+
recomputed_count: recomputed.size
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def snapshot_pre_state! # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
60
|
+
@execution.compiled_graph.nodes.each do |node|
|
|
61
|
+
state = @execution.cache.fetch(node.name)
|
|
62
|
+
@pre_node_vv[node.name] = state&.value_version
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@execution.compiled_graph.outputs.each do |output_node|
|
|
66
|
+
src_state = @execution.cache.fetch(output_node.source_root)
|
|
67
|
+
@pre_output_values[output_node.name] = {
|
|
68
|
+
value: src_state&.value,
|
|
69
|
+
value_version: src_state&.value_version
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def detect_changed_nodes
|
|
75
|
+
@execution.compiled_graph.nodes.each_with_object([]) do |node, memo|
|
|
76
|
+
pre_vv = @pre_node_vv[node.name]
|
|
77
|
+
current_vv = @execution.cache.fetch(node.name)&.value_version
|
|
78
|
+
# Changed = value_version advanced AND it's a compute/effect node
|
|
79
|
+
next unless %i[compute effect].include?(node.kind)
|
|
80
|
+
next unless current_vv && current_vv != pre_vv
|
|
81
|
+
|
|
82
|
+
memo << node.name
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def detect_changed_outputs
|
|
87
|
+
@execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
|
|
88
|
+
pre = @pre_output_values[output_node.name]
|
|
89
|
+
next unless pre
|
|
90
|
+
|
|
91
|
+
src_state = @execution.cache.fetch(output_node.source_root)
|
|
92
|
+
current_vv = src_state&.value_version
|
|
93
|
+
|
|
94
|
+
next if pre[:value_version] == current_vv
|
|
95
|
+
|
|
96
|
+
memo[output_node.name] = { from: pre[:value], to: src_state&.value }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fetch_node_kind(node_name)
|
|
101
|
+
return nil unless node_name
|
|
102
|
+
return nil unless @execution.compiled_graph.node?(node_name)
|
|
103
|
+
|
|
104
|
+
@execution.compiled_graph.fetch_node(node_name).kind
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/incremental/result"
|
|
5
|
+
require "igniter/incremental/tracker"
|
|
6
|
+
require "igniter/incremental/formatter"
|
|
7
|
+
|
|
8
|
+
module Igniter
|
|
9
|
+
# Incremental computation for Igniter contracts.
|
|
10
|
+
#
|
|
11
|
+
# Implements the Salsa/Adapton incremental model:
|
|
12
|
+
# - Each compute node tracks a dep_snapshot: the value_versions of its
|
|
13
|
+
# dependencies at last compute time.
|
|
14
|
+
# - On re-resolution of a stale node, if all dep value_versions are
|
|
15
|
+
# unchanged, the compute is skipped entirely (memoization).
|
|
16
|
+
# - If a node recomputes but produces the same output value, its own
|
|
17
|
+
# value_version is not incremented (backdating), preventing unnecessary
|
|
18
|
+
# downstream recomputation.
|
|
19
|
+
#
|
|
20
|
+
# These optimizations are built into the core runtime (NodeState, Cache,
|
|
21
|
+
# Resolver) and are always active. This module adds the reporting API:
|
|
22
|
+
# - contract.resolve_incrementally → Incremental::Result
|
|
23
|
+
#
|
|
24
|
+
# Usage:
|
|
25
|
+
# require "igniter/extensions/incremental"
|
|
26
|
+
#
|
|
27
|
+
# class PricingContract < Igniter::Contract
|
|
28
|
+
# define do
|
|
29
|
+
# input :base_price
|
|
30
|
+
# input :user_tier
|
|
31
|
+
# input :exchange_rate
|
|
32
|
+
# compute :tier_discount, depends_on: :user_tier, call: -> (user_tier:) { ... }
|
|
33
|
+
# compute :adjusted_price, depends_on: %i[base_price tier_discount], call: -> (**) { ... }
|
|
34
|
+
# compute :converted_price, depends_on: %i[adjusted_price exchange_rate], call: -> (**) { ... }
|
|
35
|
+
# output :converted_price
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# contract = PricingContract.new(base_price: 100, user_tier: "gold", exchange_rate: 1.0)
|
|
40
|
+
# contract.resolve_all
|
|
41
|
+
#
|
|
42
|
+
# result = contract.resolve_incrementally(exchange_rate: 1.12)
|
|
43
|
+
# result.skipped_nodes # => [:tier_discount, :adjusted_price]
|
|
44
|
+
# result.changed_outputs # => { converted_price: { from: 100.0, to: 112.0 } }
|
|
45
|
+
# result.explain
|
|
46
|
+
#
|
|
47
|
+
module Incremental
|
|
48
|
+
class IncrementalError < Igniter::Error; end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -35,18 +35,46 @@ module Igniter
|
|
|
35
35
|
end
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
class DeepgramConfig
|
|
39
|
+
attr_accessor :api_key, :base_url, :timeout
|
|
40
|
+
|
|
41
|
+
def initialize
|
|
42
|
+
@api_key = ENV["DEEPGRAM_API_KEY"]
|
|
43
|
+
@base_url = "https://api.deepgram.com"
|
|
44
|
+
@timeout = 300
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class AssemblyAIConfig
|
|
49
|
+
attr_accessor :api_key, :base_url, :timeout, :poll_interval, :poll_timeout
|
|
50
|
+
|
|
51
|
+
def initialize
|
|
52
|
+
@api_key = ENV["ASSEMBLYAI_API_KEY"]
|
|
53
|
+
@base_url = "https://api.assemblyai.com"
|
|
54
|
+
@timeout = 60
|
|
55
|
+
@poll_interval = 2
|
|
56
|
+
@poll_timeout = 300
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
PROVIDERS = %i[ollama anthropic openai].freeze
|
|
61
|
+
TRANSCRIPTION_PROVIDERS = %i[openai deepgram assemblyai].freeze
|
|
39
62
|
|
|
40
63
|
attr_accessor :default_provider
|
|
41
|
-
attr_reader :providers
|
|
64
|
+
attr_reader :providers, :transcription_providers
|
|
42
65
|
|
|
43
|
-
def initialize
|
|
66
|
+
def initialize # rubocop:disable Metrics/MethodLength
|
|
44
67
|
@default_provider = :ollama
|
|
45
68
|
@providers = {
|
|
46
69
|
ollama: OllamaConfig.new,
|
|
47
70
|
anthropic: AnthropicConfig.new,
|
|
48
71
|
openai: OpenAIConfig.new
|
|
49
72
|
}
|
|
73
|
+
@transcription_providers = {
|
|
74
|
+
openai: @providers[:openai], # reuse existing OpenAI config
|
|
75
|
+
deepgram: DeepgramConfig.new,
|
|
76
|
+
assemblyai: AssemblyAIConfig.new
|
|
77
|
+
}
|
|
50
78
|
end
|
|
51
79
|
|
|
52
80
|
def ollama
|
|
@@ -61,8 +89,24 @@ module Igniter
|
|
|
61
89
|
@providers[:openai]
|
|
62
90
|
end
|
|
63
91
|
|
|
92
|
+
def deepgram
|
|
93
|
+
@transcription_providers[:deepgram]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def assemblyai
|
|
97
|
+
@transcription_providers[:assemblyai]
|
|
98
|
+
end
|
|
99
|
+
|
|
64
100
|
def provider_config(name)
|
|
65
|
-
@providers.fetch(name.to_sym)
|
|
101
|
+
@providers.fetch(name.to_sym) do
|
|
102
|
+
raise ArgumentError, "Unknown LLM provider: #{name}. Available: #{PROVIDERS.inspect}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def transcription_provider_config(name)
|
|
107
|
+
@transcription_providers.fetch(name.to_sym) do
|
|
108
|
+
raise ArgumentError, "Unknown transcription provider: #{name}. Available: #{TRANSCRIPTION_PROVIDERS.inspect}"
|
|
109
|
+
end
|
|
66
110
|
end
|
|
67
111
|
end
|
|
68
112
|
end
|
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Igniter
|
|
4
6
|
module LLM
|
|
7
|
+
# Raised when the tool-use loop exceeds max_tool_iterations.
|
|
8
|
+
class ToolLoopError < Error; end
|
|
9
|
+
|
|
5
10
|
# Base class for LLM-powered compute nodes.
|
|
6
11
|
#
|
|
7
12
|
# Subclass and override #call(**inputs) to build prompts and get completions.
|
|
8
13
|
# Use the #complete and #chat helper methods inside #call.
|
|
9
14
|
#
|
|
10
|
-
#
|
|
15
|
+
# == Simple usage (no tools)
|
|
16
|
+
#
|
|
11
17
|
# class DocumentSummarizer < Igniter::LLM::Executor
|
|
12
18
|
# provider :ollama
|
|
13
19
|
# model "llama3.2"
|
|
@@ -18,25 +24,68 @@ module Igniter
|
|
|
18
24
|
# end
|
|
19
25
|
# end
|
|
20
26
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
27
|
+
# == With Igniter::Tool classes (auto tool-use loop)
|
|
28
|
+
#
|
|
29
|
+
# class ResearchAgent < Igniter::LLM::Executor
|
|
30
|
+
# provider :anthropic
|
|
31
|
+
# model "claude-sonnet-4-6"
|
|
32
|
+
# system_prompt "You are a research assistant. Use tools when needed."
|
|
33
|
+
#
|
|
34
|
+
# tools SearchWeb, WriteFile # Igniter::Tool subclasses
|
|
35
|
+
# capabilities :web_access, :filesystem_write
|
|
36
|
+
# max_tool_iterations 10
|
|
37
|
+
#
|
|
38
|
+
# def call(question:)
|
|
39
|
+
# complete(question)
|
|
40
|
+
# # Auto-loop: LLM → tool_use → capability check → Tool#call → result → LLM
|
|
26
41
|
# end
|
|
27
42
|
# end
|
|
43
|
+
#
|
|
44
|
+
# == Provider failover
|
|
45
|
+
#
|
|
46
|
+
# class MyAgent < Igniter::LLM::Executor
|
|
47
|
+
# provider :anthropic, fallback: [:openai, :ollama]
|
|
48
|
+
# model "claude-sonnet-4-6", fallback: ["gpt-4o", "llama3.2"]
|
|
49
|
+
# # On ProviderError, retries with OpenAI/GPT-4o, then Ollama/llama3.2
|
|
50
|
+
# end
|
|
28
51
|
class Executor < Igniter::Executor
|
|
29
52
|
class << self
|
|
30
|
-
|
|
31
|
-
|
|
53
|
+
# Set or get the primary provider, with optional fallback chain.
|
|
54
|
+
#
|
|
55
|
+
# @param name [Symbol, nil] :ollama, :anthropic, or :openai
|
|
56
|
+
# @param fallback [Array<Symbol>] providers to try on ProviderError, in order
|
|
57
|
+
def provider(name = nil, fallback: nil)
|
|
58
|
+
if name.nil?
|
|
59
|
+
@provider_chain&.first || Igniter::LLM.config.default_provider
|
|
60
|
+
else
|
|
61
|
+
chain = [name] + Array(fallback)
|
|
62
|
+
@provider_chain = chain.map(&:to_sym)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
32
65
|
|
|
33
|
-
|
|
66
|
+
# Full provider chain (primary + fallbacks).
|
|
67
|
+
def provider_chain
|
|
68
|
+
@provider_chain&.dup || [provider]
|
|
34
69
|
end
|
|
35
70
|
|
|
36
|
-
|
|
37
|
-
|
|
71
|
+
# Set or get the primary model, with optional fallback list.
|
|
72
|
+
# Fallback models align positionally with the provider fallback chain.
|
|
73
|
+
#
|
|
74
|
+
# @param name [String, nil]
|
|
75
|
+
# @param fallback [Array<String>] models to use per fallback provider
|
|
76
|
+
def model(name = nil, fallback: nil)
|
|
77
|
+
if name.nil?
|
|
78
|
+
@model_chain&.first || default_model_for(
|
|
79
|
+
@provider_chain&.first || Igniter::LLM.config.default_provider
|
|
80
|
+
)
|
|
81
|
+
else
|
|
82
|
+
@model_chain = [name] + Array(fallback)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
38
85
|
|
|
39
|
-
|
|
86
|
+
# Full model chain (primary + fallbacks).
|
|
87
|
+
def model_chain
|
|
88
|
+
@model_chain&.dup || [model]
|
|
40
89
|
end
|
|
41
90
|
|
|
42
91
|
def system_prompt(text = nil)
|
|
@@ -51,23 +100,43 @@ module Igniter
|
|
|
51
100
|
@temperature = val
|
|
52
101
|
end
|
|
53
102
|
|
|
103
|
+
# Register tools. Accepts Igniter::Tool subclasses (enables auto tool-use
|
|
104
|
+
# loop in #complete) or raw Hash definitions (backward-compatible with
|
|
105
|
+
# the deferred #complete_with_tools pattern).
|
|
54
106
|
def tools(*tool_definitions)
|
|
55
107
|
return @tools || [] if tool_definitions.empty?
|
|
56
108
|
|
|
57
109
|
@tools = tool_definitions.flatten
|
|
58
110
|
end
|
|
59
111
|
|
|
112
|
+
# Maximum number of tool-use iterations in the auto-loop.
|
|
113
|
+
# Prevents infinite loops when the LLM keeps requesting tools.
|
|
114
|
+
# Ignored when no Tool classes are registered. Default: 10.
|
|
115
|
+
def max_tool_iterations(n = nil)
|
|
116
|
+
n ? (@max_tool_iterations = n.to_i) : (@max_tool_iterations || 10)
|
|
117
|
+
end
|
|
118
|
+
|
|
60
119
|
def inherited(subclass)
|
|
61
120
|
super
|
|
121
|
+
subclass.instance_variable_set(:@provider_chain, @provider_chain&.dup)
|
|
122
|
+
subclass.instance_variable_set(:@model_chain, @model_chain&.dup)
|
|
123
|
+
# Keep legacy @provider / @model ivars in sync for backward compat
|
|
62
124
|
subclass.instance_variable_set(:@provider, @provider)
|
|
63
125
|
subclass.instance_variable_set(:@model, @model)
|
|
64
126
|
subclass.instance_variable_set(:@system_prompt, @system_prompt)
|
|
65
127
|
subclass.instance_variable_set(:@temperature, @temperature)
|
|
66
128
|
subclass.instance_variable_set(:@tools, @tools&.dup)
|
|
129
|
+
subclass.instance_variable_set(:@max_tool_iterations, @max_tool_iterations)
|
|
67
130
|
end
|
|
68
131
|
|
|
69
132
|
private
|
|
70
133
|
|
|
134
|
+
def default_model_for(prov)
|
|
135
|
+
Igniter::LLM.config.provider_config(prov).default_model
|
|
136
|
+
rescue StandardError
|
|
137
|
+
"llama3.2"
|
|
138
|
+
end
|
|
139
|
+
|
|
71
140
|
def provider_config
|
|
72
141
|
Igniter::LLM.provider_instance(provider).instance_of?(Class) ? nil : Igniter::LLM.config.provider_config(provider)
|
|
73
142
|
rescue StandardError
|
|
@@ -75,7 +144,7 @@ module Igniter
|
|
|
75
144
|
end
|
|
76
145
|
end
|
|
77
146
|
|
|
78
|
-
attr_reader :last_usage, :last_context
|
|
147
|
+
attr_reader :last_usage, :last_context, :last_provider, :last_model, :call_history
|
|
79
148
|
|
|
80
149
|
# Subclasses override this method. Use #complete or #chat inside.
|
|
81
150
|
def call(**_inputs)
|
|
@@ -84,17 +153,42 @@ module Igniter
|
|
|
84
153
|
|
|
85
154
|
protected
|
|
86
155
|
|
|
87
|
-
# Single-turn completion
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
156
|
+
# Single-turn completion, or auto tool-use loop when Igniter::Tool subclasses
|
|
157
|
+
# are registered via the +tools+ DSL.
|
|
158
|
+
#
|
|
159
|
+
# Wraps execution in the provider failover chain: on ProviderError the next
|
|
160
|
+
# provider/model pair is tried. ConfigurationError is NOT caught — a missing
|
|
161
|
+
# API key is a configuration bug, not a transient provider failure.
|
|
162
|
+
#
|
|
163
|
+
# @param prompt [String]
|
|
164
|
+
# @param context [Context, nil]
|
|
165
|
+
# @return [String] final LLM text response
|
|
166
|
+
def complete(prompt, context: nil) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
167
|
+
with_provider_fallback do
|
|
168
|
+
# Accept both Igniter::Tool and Igniter::Skill subclasses (duck-type check).
|
|
169
|
+
# Using respond_to? avoids a circular require: skill.rb requires this file,
|
|
170
|
+
# so we can't safely reference Igniter::Skill or Igniter::Tool::Discoverable here.
|
|
171
|
+
tool_classes = self.class.tools.select do |t|
|
|
172
|
+
t.is_a?(Class) && t.respond_to?(:tool_name) && t.respond_to?(:to_schema)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
result = if tool_classes.any?
|
|
176
|
+
run_tool_loop(prompt: prompt, context: context, tool_classes: tool_classes)
|
|
177
|
+
else
|
|
178
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
179
|
+
response = provider_instance.chat(
|
|
180
|
+
messages: messages,
|
|
181
|
+
model: current_model,
|
|
182
|
+
**completion_options
|
|
183
|
+
)
|
|
184
|
+
@last_usage = provider_instance.last_usage
|
|
185
|
+
@last_context = track_context(context, prompt, response[:content])
|
|
186
|
+
response[:content]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
track_call_history(prompt, result)
|
|
190
|
+
result
|
|
191
|
+
end
|
|
98
192
|
end
|
|
99
193
|
|
|
100
194
|
# Multi-turn chat with a Context object or raw messages array.
|
|
@@ -102,19 +196,21 @@ module Igniter
|
|
|
102
196
|
messages = context.is_a?(Context) ? context.to_a : Array(context)
|
|
103
197
|
response = provider_instance.chat(
|
|
104
198
|
messages: messages,
|
|
105
|
-
model:
|
|
199
|
+
model: current_model,
|
|
106
200
|
**completion_options
|
|
107
201
|
)
|
|
108
202
|
@last_usage = provider_instance.last_usage
|
|
109
203
|
response[:content]
|
|
110
204
|
end
|
|
111
205
|
|
|
112
|
-
# Tool-use call
|
|
206
|
+
# Tool-use call for the distributed/deferred pattern.
|
|
207
|
+
# Returns DeferredResult if the LLM requests a tool call.
|
|
208
|
+
# For automatic tool execution, use #complete with Igniter::Tool classes.
|
|
113
209
|
def complete_with_tools(prompt, context: nil) # rubocop:disable Metrics/MethodLength
|
|
114
210
|
messages = build_messages(prompt: prompt, context: context)
|
|
115
211
|
response = provider_instance.chat(
|
|
116
212
|
messages: messages,
|
|
117
|
-
model:
|
|
213
|
+
model: current_model,
|
|
118
214
|
tools: self.class.tools,
|
|
119
215
|
**completion_options
|
|
120
216
|
)
|
|
@@ -129,8 +225,99 @@ module Igniter
|
|
|
129
225
|
|
|
130
226
|
private
|
|
131
227
|
|
|
228
|
+
# Iterate through the provider/model chain, retrying on ProviderError.
|
|
229
|
+
# ConfigurationError propagates immediately (missing API key = config bug).
|
|
230
|
+
#
|
|
231
|
+
# Does NOT call Igniter::LLM.provider_instance directly; instead it resets
|
|
232
|
+
# @provider_instance = nil before each attempt so that the #provider_instance
|
|
233
|
+
# accessor re-evaluates (or a test's define_method override takes effect).
|
|
234
|
+
def with_provider_fallback # rubocop:disable Metrics/MethodLength
|
|
235
|
+
chain = self.class.provider_chain
|
|
236
|
+
mchain = self.class.model_chain
|
|
237
|
+
last_error = nil
|
|
238
|
+
|
|
239
|
+
chain.each_with_index do |prov, i|
|
|
240
|
+
@last_provider = prov
|
|
241
|
+
@last_model = mchain[i] # nil when chain is shorter → current_model falls back
|
|
242
|
+
@provider_instance = nil # clear memo; forces re-evaluation per attempt
|
|
243
|
+
|
|
244
|
+
begin
|
|
245
|
+
return yield
|
|
246
|
+
rescue Igniter::LLM::ProviderError => e
|
|
247
|
+
last_error = e
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
raise last_error
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The model to use for the current request (set by with_provider_fallback,
|
|
255
|
+
# or falls back to the class-level default).
|
|
256
|
+
def current_model
|
|
257
|
+
@last_model || self.class.model
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Execute the tool-use loop until the LLM produces a plain-text response.
|
|
261
|
+
def run_tool_loop(prompt:, context:, tool_classes:) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
262
|
+
messages = build_messages(prompt: prompt, context: context)
|
|
263
|
+
schemas = tool_classes.map(&:to_schema)
|
|
264
|
+
allowed_caps = self.class.declared_capabilities
|
|
265
|
+
max_iters = self.class.max_tool_iterations
|
|
266
|
+
iters = 0
|
|
267
|
+
|
|
268
|
+
loop do
|
|
269
|
+
response = provider_instance.chat(
|
|
270
|
+
messages: messages,
|
|
271
|
+
model: current_model,
|
|
272
|
+
tools: schemas,
|
|
273
|
+
**completion_options
|
|
274
|
+
)
|
|
275
|
+
@last_usage = provider_instance.last_usage
|
|
276
|
+
|
|
277
|
+
return response[:content] if response[:tool_calls].empty?
|
|
278
|
+
|
|
279
|
+
iters += 1
|
|
280
|
+
if iters > max_iters
|
|
281
|
+
raise ToolLoopError,
|
|
282
|
+
"Tool loop exceeded max_tool_iterations (#{max_iters}) for #{self.class.name}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Append assistant's tool-use turn (preserves tool_call ids for Anthropic/OpenAI)
|
|
286
|
+
messages << {
|
|
287
|
+
role: "assistant",
|
|
288
|
+
content: response[:content],
|
|
289
|
+
tool_calls: response[:tool_calls]
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# Execute each requested tool and collect results
|
|
293
|
+
results = response[:tool_calls].map do |tc|
|
|
294
|
+
klass = tool_classes.find { |k| k.tool_name == tc[:name].to_s }
|
|
295
|
+
content = dispatch_tool(klass, tc, allowed_caps)
|
|
296
|
+
{ id: tc[:id].to_s, name: tc[:name].to_s, content: content }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# All results for this iteration go into a single :tool_results message.
|
|
300
|
+
# Each provider's normalize_messages converts this to its native format.
|
|
301
|
+
messages << { role: :tool_results, results: results }
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def dispatch_tool(klass, tool_call, allowed_caps)
|
|
306
|
+
return "Unknown tool: #{tool_call[:name]}" unless klass
|
|
307
|
+
|
|
308
|
+
result = klass.new.call_with_capability_check!(
|
|
309
|
+
allowed_capabilities: allowed_caps,
|
|
310
|
+
**tool_call[:arguments]
|
|
311
|
+
)
|
|
312
|
+
result.is_a?(String) ? result : JSON.generate(result)
|
|
313
|
+
rescue Igniter::Tool::CapabilityError
|
|
314
|
+
raise
|
|
315
|
+
rescue StandardError => e
|
|
316
|
+
"Error: #{e.class}: #{e.message}"
|
|
317
|
+
end
|
|
318
|
+
|
|
132
319
|
def provider_instance
|
|
133
|
-
@provider_instance ||= Igniter::LLM.provider_instance(self.class.provider)
|
|
320
|
+
@provider_instance ||= Igniter::LLM.provider_instance(@last_provider || self.class.provider)
|
|
134
321
|
end
|
|
135
322
|
|
|
136
323
|
def build_messages(prompt:, context: nil)
|
|
@@ -154,6 +341,12 @@ module Igniter
|
|
|
154
341
|
ctx = existing.is_a?(Context) ? existing : Context.empty(system: self.class.system_prompt)
|
|
155
342
|
ctx.append_user(user_prompt).append_assistant(assistant_reply)
|
|
156
343
|
end
|
|
344
|
+
|
|
345
|
+
def track_call_history(input, output)
|
|
346
|
+
@call_history ||= []
|
|
347
|
+
@call_history << { input: input, output: output.to_s, timestamp: Time.now }
|
|
348
|
+
@call_history = @call_history.last(20) if @call_history.size > 20
|
|
349
|
+
end
|
|
157
350
|
end
|
|
158
351
|
end
|
|
159
352
|
end
|