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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # Mission: become better.
6
+ #
7
+ # An evolutionary self-improvement agent that maintains a population of
8
+ # strategies (prompt variants, parameter configs, tool selections — any
9
+ # Hash), evaluates their fitness, selects survivors, generates mutations,
10
+ # and tracks lineage across generations.
11
+ #
12
+ # The agent is intentionally domain-agnostic. Strategies are plain Hashes
13
+ # whose semantics are defined by the caller's +fitness_fn+.
14
+ #
15
+ # Life-cycle of one generation:
16
+ # seed → evaluate_population → evolve (or: seed → run_generation × N)
17
+ #
18
+ # Mutation:
19
+ # * **Rule-based** (default) — numeric values are scaled ±20 %, booleans
20
+ # are flipped, arrays are sub-sampled. Strings are kept unchanged.
21
+ # * **LLM-assisted** — delegates to any callable that accepts
22
+ # +strategy:+ and +generation:+ and returns a Hash.
23
+ #
24
+ # @example Evolve a prompt configuration
25
+ # fitness = ->(config) { MyEvaluator.score(config) }
26
+ # ref = EvolutionAgent.start(initial_state: {
27
+ # fitness_fn: fitness,
28
+ # population_size: 4
29
+ # })
30
+ # ref.send(:seed, strategies: [
31
+ # { temperature: 0.7, max_tokens: 512 },
32
+ # { temperature: 0.3, max_tokens: 256 }
33
+ # ])
34
+ # 3.times { ref.send(:run_generation) }
35
+ # best = ref.call(:best) # => Strategy struct
36
+ class EvolutionAgent < Igniter::Agent
37
+ # An individual strategy in the population.
38
+ Strategy = Struct.new(:id, :config, :fitness, :generation,
39
+ :parent_ids, :mutations, keyword_init: true)
40
+
41
+ # Summary record for one completed generation.
42
+ GenerationReport = Struct.new(:generation, :population_size, :best_fitness,
43
+ :mean_fitness, :best_id, keyword_init: true)
44
+
45
+ initial_state \
46
+ population: [],
47
+ generation: 0,
48
+ history: [],
49
+ best_strategy: nil,
50
+ fitness_fn: nil,
51
+ llm: nil,
52
+ population_size: 6,
53
+ mutation_rate: 0.3,
54
+ elite_fraction: 0.5
55
+
56
+ # Seed the population with an initial set of strategy configs.
57
+ # Resets generation counter and history.
58
+ #
59
+ # Payload keys:
60
+ # strategies [Array<Hash>] — initial strategy configs
61
+ on :seed do |state:, payload:|
62
+ pop = Array(payload.fetch(:strategies)).each_with_index.map do |cfg, i|
63
+ Strategy.new(
64
+ id: "gen0_#{i}",
65
+ config: cfg,
66
+ fitness: nil,
67
+ generation: 0,
68
+ parent_ids: [],
69
+ mutations: []
70
+ )
71
+ end
72
+ state.merge(population: pop, generation: 0, history: [], best_strategy: nil)
73
+ end
74
+
75
+ # Score every strategy in the population using the fitness function.
76
+ # Strategies that raise are assigned fitness 0.0.
77
+ #
78
+ # Payload keys:
79
+ # fitness_fn [#call, nil] — override the state fitness function
80
+ on :evaluate_population do |state:, payload:|
81
+ fitness_fn = (payload && payload[:fitness_fn]) || state[:fitness_fn]
82
+ next state unless fitness_fn
83
+
84
+ agent = new
85
+ evaluated = agent.send(:score_population, state[:population], fitness_fn)
86
+ best = evaluated.max_by { |s| s.fitness || -Float::INFINITY }
87
+ state.merge(population: evaluated, best_strategy: best)
88
+ end
89
+
90
+ # Select elite survivors, generate children by mutation, increment generation.
91
+ # Requires the population to be already evaluated (see +:evaluate_population+).
92
+ on :evolve do |state:, payload:|
93
+ agent = new
94
+ new_pop, report = agent.send(:run_evolution, state)
95
+ next state unless new_pop
96
+
97
+ best = new_pop.max_by { |s| s.fitness || -Float::INFINITY }
98
+ state.merge(
99
+ population: new_pop,
100
+ generation: state[:generation] + 1,
101
+ history: state[:history] + [report],
102
+ best_strategy: best
103
+ )
104
+ end
105
+
106
+ # Convenience: evaluate the current population then evolve — one full cycle.
107
+ #
108
+ # Payload keys:
109
+ # fitness_fn [#call, nil] — override the state fitness function
110
+ on :run_generation do |state:, payload:|
111
+ fitness_fn = (payload && payload[:fitness_fn]) || state[:fitness_fn]
112
+ next state unless fitness_fn
113
+
114
+ agent = new
115
+
116
+ # evaluate
117
+ evaluated_pop = agent.send(:score_population, state[:population], fitness_fn)
118
+ with_scores = state.merge(population: evaluated_pop)
119
+
120
+ # evolve
121
+ new_pop, report = agent.send(:run_evolution, with_scores)
122
+ next with_scores unless new_pop
123
+
124
+ best = new_pop.max_by { |s| s.fitness || -Float::INFINITY }
125
+ with_scores.merge(
126
+ population: new_pop,
127
+ generation: state[:generation] + 1,
128
+ history: state[:history] + [report],
129
+ best_strategy: best
130
+ )
131
+ end
132
+
133
+ # Add a single mutated child of a given strategy to the population.
134
+ #
135
+ # Payload keys:
136
+ # id [String] — id of the parent strategy
137
+ on :mutate_strategy do |state:, payload:|
138
+ parent = state[:population].find { |s| s.id == payload.fetch(:id) }
139
+ next state unless parent
140
+
141
+ agent = new
142
+ child = agent.send(:mutate_one, parent, state, state[:population].size)
143
+ state.merge(population: state[:population] + [child])
144
+ end
145
+
146
+ # Sync query — best evaluated strategy so far.
147
+ #
148
+ # @return [Strategy, nil]
149
+ on :best do |state:, **|
150
+ state[:best_strategy]
151
+ end
152
+
153
+ # Sync query — current population.
154
+ #
155
+ # @return [Array<Strategy>]
156
+ on :population do |state:, **|
157
+ state[:population].dup
158
+ end
159
+
160
+ # Sync query — evolution history (one GenerationReport per generation).
161
+ #
162
+ # @return [Array<GenerationReport>]
163
+ on :history do |state:, **|
164
+ state[:history].dup
165
+ end
166
+
167
+ # Sync query — current generation number.
168
+ #
169
+ # @return [Integer]
170
+ on :generation do |state:, **|
171
+ state[:generation]
172
+ end
173
+
174
+ # Update agent configuration.
175
+ #
176
+ # Payload keys:
177
+ # fitness_fn [#call]
178
+ # llm [#call]
179
+ # population_size [Integer]
180
+ # mutation_rate [Float] — 0.0–1.0
181
+ # elite_fraction [Float] — 0.0–1.0
182
+ on :configure do |state:, payload:|
183
+ state.merge(
184
+ payload.slice(:fitness_fn, :llm, :population_size,
185
+ :mutation_rate, :elite_fraction).compact
186
+ )
187
+ end
188
+
189
+ # Clear population and history (configuration is preserved).
190
+ on :reset do |state:, **|
191
+ state.merge(population: [], generation: 0, history: [], best_strategy: nil)
192
+ end
193
+
194
+ private
195
+
196
+ def score_population(population, fitness_fn)
197
+ population.map do |s|
198
+ score = begin
199
+ fitness_fn.call(s.config).to_f
200
+ rescue StandardError
201
+ 0.0
202
+ end
203
+ Strategy.new(
204
+ id: s.id, config: s.config, fitness: score,
205
+ generation: s.generation, parent_ids: s.parent_ids, mutations: s.mutations
206
+ )
207
+ end
208
+ end
209
+
210
+ def run_evolution(state)
211
+ evaluated = state[:population].reject { |s| s.fitness.nil? }
212
+ return [nil, nil] if evaluated.empty?
213
+
214
+ elite_count = [(evaluated.size * state[:elite_fraction]).ceil, 1].max
215
+ elite = evaluated.sort_by { |s| -(s.fitness || 0) }.first(elite_count)
216
+
217
+ fitnesses = elite.map { |s| s.fitness.to_f }
218
+ report = GenerationReport.new(
219
+ generation: state[:generation],
220
+ population_size: state[:population].size,
221
+ best_fitness: fitnesses.max.round(6),
222
+ mean_fitness: (fitnesses.sum / fitnesses.size).round(6),
223
+ best_id: elite.first.id
224
+ )
225
+
226
+ target = [state[:population_size], elite.size].max
227
+ children = []
228
+ idx = 0
229
+ until elite.size + children.size >= target
230
+ parent = elite[idx % elite.size]
231
+ child = mutate_one(parent, state, elite.size + idx)
232
+ children << child
233
+ idx += 1
234
+ end
235
+
236
+ [elite + children, report]
237
+ end
238
+
239
+ def mutate_one(parent, state, idx)
240
+ next_gen = state[:generation] + 1
241
+ config = if state[:llm]
242
+ mutate_with_llm(state[:llm], parent.config, next_gen)
243
+ else
244
+ mutate_rule_based(parent.config, state[:mutation_rate])
245
+ end
246
+
247
+ Strategy.new(
248
+ id: "gen#{next_gen}_#{idx}",
249
+ config: config,
250
+ fitness: nil,
251
+ generation: next_gen,
252
+ parent_ids: [parent.id],
253
+ mutations: detect_mutations(parent.config, config)
254
+ )
255
+ end
256
+
257
+ def mutate_rule_based(config, rate)
258
+ config.each_with_object({}) do |(k, v), acc|
259
+ acc[k] = rand < rate ? mutate_value(v) : v
260
+ end
261
+ end
262
+
263
+ def mutate_value(value)
264
+ case value
265
+ when Numeric then (value * (0.8 + rand * 0.4)).round(6)
266
+ when TrueClass, FalseClass then !value
267
+ when Array then value.empty? ? value : value.sample([1, rand(value.size)].max)
268
+ else value
269
+ end
270
+ end
271
+
272
+ def mutate_with_llm(llm, config, generation)
273
+ result = llm.call(strategy: config, generation: generation)
274
+ result.is_a?(Hash) ? result : mutate_rule_based(config, 0.3)
275
+ rescue StandardError
276
+ mutate_rule_based(config, 0.3)
277
+ end
278
+
279
+ def detect_mutations(original, mutated)
280
+ original.keys.filter_map do |k|
281
+ "#{k}: #{original[k].inspect} → #{mutated[k].inspect}" if original[k] != mutated[k]
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../proactive_agent"
4
+
5
+ module Igniter
6
+ module Agents
7
+ # Polls a set of named services and tracks their health status.
8
+ #
9
+ # HealthCheckAgent wraps ProactiveAgent with a +check+ DSL keyword that
10
+ # registers both the watcher (poll callable) and a trigger that fires
11
+ # when a service transitions to an unhealthy state.
12
+ #
13
+ # The poll callable should:
14
+ # * Return a truthy value when the service is healthy.
15
+ # * Return falsy OR raise when the service is unhealthy.
16
+ #
17
+ # @example
18
+ # class InfraHealth < Igniter::Agents::HealthCheckAgent
19
+ # intent "Monitor database, cache, and payment gateway"
20
+ # scan_interval 30.0
21
+ #
22
+ # check :database, poll: -> { DB.ping }
23
+ # check :redis, poll: -> { Redis.current.ping == "PONG" }
24
+ # check :payment_gateway, poll: -> { PaymentClient.health_check }
25
+ # end
26
+ #
27
+ # ref = InfraHealth.start
28
+ # status = ref.call(:health) # => { database: :healthy, redis: :unhealthy, … }
29
+ # all_ok = ref.call(:all_healthy)
30
+ class HealthCheckAgent < ProactiveAgent
31
+ # Recorded when a service's health changes (healthy ↔ unhealthy).
32
+ Transition = Struct.new(:service, :from, :to, :occurred_at, keyword_init: true)
33
+
34
+ proactive_initial_state health: {}, transitions: []
35
+
36
+ class << self
37
+ # Register a service health check.
38
+ # Calling +check+ registers both the watcher and an unhealthy trigger.
39
+ #
40
+ # @param service [Symbol, String]
41
+ # @param poll [#call] — returns truthy = healthy, falsy/raises = unhealthy
42
+ def check(service, poll:)
43
+ name = service.to_sym
44
+
45
+ # Watcher: map poll result to :healthy / :unhealthy symbol
46
+ watch(name, poll: -> {
47
+ begin
48
+ poll.call ? :healthy : :unhealthy
49
+ rescue StandardError
50
+ :unhealthy
51
+ end
52
+ })
53
+
54
+ # Trigger: fires when service is unhealthy AND previous status differs
55
+ trigger(:"health_#{name}",
56
+ condition: ->(ctx) { ctx[name] == :unhealthy },
57
+ action: ->(state:, context:) {
58
+ prev = state[:health][name]
59
+ status = context[name]
60
+ health = state[:health].merge(name => status)
61
+
62
+ # Only record a transition when the status actually changes
63
+ if prev != status
64
+ t = Transition.new(
65
+ service: name,
66
+ from: prev || :unknown,
67
+ to: status,
68
+ occurred_at: Time.now
69
+ )
70
+ transitions = (state[:transitions] + [t]).last(100)
71
+ state.merge(health: health, transitions: transitions)
72
+ else
73
+ state.merge(health: health)
74
+ end
75
+ }
76
+ )
77
+ end
78
+ end
79
+
80
+ # ── Inheritance ────────────────────────────────────────────────────────
81
+ # Re-inject HealthCheckAgent-specific handlers so that anonymous test
82
+ # classes (Class.new(HealthCheckAgent)) also receive them.
83
+ def self.inherited(subclass)
84
+ super # ProactiveAgent.inherited → resets @handlers, injects proactive ones
85
+ inject_health_handlers!(subclass)
86
+ end
87
+
88
+ private_class_method def self.inject_health_handlers!(klass)
89
+ klass.on(:health) { |state:, **| state.fetch(:health, {}).dup }
90
+ klass.on(:all_healthy) { |state:, **| state.fetch(:health, {}).values.all? { |v| v == :healthy } }
91
+ klass.on(:transitions) { |state:, **| state.fetch(:transitions, []).dup }
92
+ klass.on(:reset) { |state:, **| state.merge(health: {}, transitions: []) }
93
+ end
94
+
95
+ # Sync query — current health status per service.
96
+ #
97
+ # @return [Hash<Symbol, :healthy | :unhealthy>]
98
+ on :health do |state:, **|
99
+ state.fetch(:health, {}).dup
100
+ end
101
+
102
+ # Sync query — true when all polled services are healthy.
103
+ #
104
+ # @return [Boolean]
105
+ on :all_healthy do |state:, **|
106
+ state.fetch(:health, {}).values.all? { |v| v == :healthy }
107
+ end
108
+
109
+ # Sync query — list of recorded health transitions.
110
+ #
111
+ # @return [Array<Transition>]
112
+ on :transitions do |state:, **|
113
+ state.fetch(:transitions, []).dup
114
+ end
115
+
116
+ # Reset health status and transition history.
117
+ on :reset do |state:, **|
118
+ state.merge(health: {}, transitions: [])
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # Passive-watching agent that records observations from other agents or
6
+ # systems, applies user-defined anomaly-detection rules, and surfaces
7
+ # detected anomalies for inspection or alerting.
8
+ #
9
+ # Design:
10
+ # * Observations are appended via +:observe+.
11
+ # * Anomaly rules are callables +:matcher+ stored with a +:name+.
12
+ # * Call +:check+ to scan new (unscanned) observations against all rules.
13
+ # The agent tracks a cursor so rules are never applied twice to the same
14
+ # observation, preventing duplicates.
15
+ #
16
+ # @example Detect error spikes
17
+ # ref = ObserverAgent.start
18
+ # ref.send(:watch, subject: :payments)
19
+ # ref.send(:add_rule,
20
+ # name: :consecutive_errors,
21
+ # matcher: ->(obs) { obs.event == :error })
22
+ # ref.send(:observe, subject: :payments, event: :error)
23
+ # ref.send(:check)
24
+ # ref.call(:anomalies) # => [Anomaly(...)]
25
+ class ObserverAgent < Igniter::Agent
26
+ # A single recorded event.
27
+ Observation = Struct.new(:subject, :event, :data, :observed_at, keyword_init: true)
28
+
29
+ # A detected rule violation.
30
+ Anomaly = Struct.new(:subject, :rule, :observation, :detected_at, keyword_init: true)
31
+
32
+ # A detection rule.
33
+ Rule = Struct.new(:name, :matcher, keyword_init: true)
34
+
35
+ # Sync-query summary.
36
+ Summary = Struct.new(:subjects, :observations, :anomalies, :rules, keyword_init: true)
37
+
38
+ initial_state \
39
+ subjects: [],
40
+ observations: [],
41
+ anomalies: [],
42
+ rules: [],
43
+ checked_until: 0,
44
+ max_observations: 500
45
+
46
+ # Register a subject to watch.
47
+ #
48
+ # Payload keys:
49
+ # subject [Symbol, String]
50
+ on :watch do |state:, payload:|
51
+ subject = payload.fetch(:subject).to_sym
52
+ next state if state[:subjects].include?(subject)
53
+
54
+ state.merge(subjects: state[:subjects] + [subject])
55
+ end
56
+
57
+ # Stop watching a subject.
58
+ #
59
+ # Payload keys:
60
+ # subject [Symbol, String]
61
+ on :unwatch do |state:, payload:|
62
+ subject = payload.fetch(:subject).to_sym
63
+ state.merge(subjects: state[:subjects] - [subject])
64
+ end
65
+
66
+ # Record an observation event.
67
+ #
68
+ # Payload keys:
69
+ # subject [Symbol, String] — source of the event
70
+ # event [Symbol, String] — event type
71
+ # data [Hash] — optional extra data
72
+ on :observe do |state:, payload:|
73
+ obs = Observation.new(
74
+ subject: payload.fetch(:subject),
75
+ event: payload.fetch(:event),
76
+ data: payload.fetch(:data, {}),
77
+ observed_at: Time.now
78
+ )
79
+ kept = (state[:observations] + [obs]).last(state[:max_observations])
80
+ state.merge(observations: kept)
81
+ end
82
+
83
+ # Add (or replace) an anomaly-detection rule.
84
+ #
85
+ # Payload keys:
86
+ # name [Symbol, String] — unique rule identifier
87
+ # matcher [#call] — callable(Observation) → truthy when anomaly
88
+ on :add_rule do |state:, payload:|
89
+ rule = Rule.new(name: payload.fetch(:name).to_sym, matcher: payload.fetch(:matcher))
90
+ rules = state[:rules].reject { |r| r.name == rule.name } + [rule]
91
+ state.merge(rules: rules)
92
+ end
93
+
94
+ # Remove a rule by name.
95
+ #
96
+ # Payload keys:
97
+ # name [Symbol, String]
98
+ on :remove_rule do |state:, payload:|
99
+ name = payload.fetch(:name).to_sym
100
+ state.merge(rules: state[:rules].reject { |r| r.name == name })
101
+ end
102
+
103
+ # Scan observations added since the last check against all rules.
104
+ # Uses a cursor to guarantee each observation is checked at most once.
105
+ #
106
+ # Payload keys:
107
+ # window [Integer, nil] — max recent observations to scan (default: all new)
108
+ on :check do |state:, payload:|
109
+ cursor = state[:checked_until]
110
+ new_obs = state[:observations].drop(cursor)
111
+ new_obs = new_obs.last(payload[:window]) if payload&.fetch(:window, nil)
112
+
113
+ detected = new_obs.flat_map do |obs|
114
+ state[:rules].filter_map do |rule|
115
+ next unless rule.matcher.call(obs)
116
+
117
+ Anomaly.new(
118
+ subject: obs.subject,
119
+ rule: rule.name,
120
+ observation: obs,
121
+ detected_at: Time.now
122
+ )
123
+ end
124
+ end
125
+
126
+ state.merge(
127
+ anomalies: state[:anomalies] + detected,
128
+ checked_until: state[:observations].size
129
+ )
130
+ end
131
+
132
+ # Sync query — return observations, optionally filtered by subject.
133
+ #
134
+ # Payload keys:
135
+ # subject [Symbol, String, nil]
136
+ #
137
+ # @return [Array<Observation>]
138
+ on :observations do |state:, payload:|
139
+ filter = payload&.fetch(:subject, nil)
140
+ obs = state[:observations]
141
+ obs = obs.select { |o| o.subject.to_s == filter.to_s } if filter
142
+ obs.dup
143
+ end
144
+
145
+ # Sync query — return anomalies, optionally filtered by subject.
146
+ #
147
+ # Payload keys:
148
+ # subject [Symbol, String, nil]
149
+ #
150
+ # @return [Array<Anomaly>]
151
+ on :anomalies do |state:, payload:|
152
+ filter = payload&.fetch(:subject, nil)
153
+ anoms = state[:anomalies]
154
+ anoms = anoms.select { |a| a.subject.to_s == filter.to_s } if filter
155
+ anoms.dup
156
+ end
157
+
158
+ # Sync query — aggregate counts.
159
+ #
160
+ # @return [Summary]
161
+ on :summary do |state:, **|
162
+ Summary.new(
163
+ subjects: state[:subjects].size,
164
+ observations: state[:observations].size,
165
+ anomalies: state[:anomalies].size,
166
+ rules: state[:rules].size
167
+ )
168
+ end
169
+
170
+ # Clear detected anomalies (observations and rules are preserved).
171
+ on :clear_anomalies do |state:, **|
172
+ state.merge(anomalies: [], checked_until: 0)
173
+ end
174
+
175
+ # Update configuration.
176
+ #
177
+ # Payload keys:
178
+ # max_observations [Integer]
179
+ on :configure do |state:, payload:|
180
+ state.merge(payload.slice(:max_observations).compact)
181
+ end
182
+ end
183
+ end
184
+ end