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.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. 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
- PROVIDERS = %i[ollama anthropic openai].freeze
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) { raise ArgumentError, "Unknown LLM provider: #{name}. Available: #{PROVIDERS.inspect}" }
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
- # Example:
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
- # class OrderContract < Igniter::Contract
22
- # define do
23
- # input :document
24
- # compute :summary, depends_on: :document, with: DocumentSummarizer
25
- # output :summary
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
- def provider(name = nil)
31
- return @provider || Igniter::LLM.config.default_provider if name.nil?
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
- @provider = name.to_sym
66
+ # Full provider chain (primary + fallbacks).
67
+ def provider_chain
68
+ @provider_chain&.dup || [provider]
34
69
  end
35
70
 
36
- def model(name = nil)
37
- return @model || provider_config.default_model if name.nil?
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
- @model = name
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. 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]
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: self.class.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. Returns DeferredResult if the LLM wants to call a tool.
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: self.class.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