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