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,324 @@
1
+ # Node Cache — v1
2
+
3
+ Cross-execution TTL cache and in-flight request coalescing for compute nodes.
4
+
5
+ Activate per-node via `cache_ttl:` and `coalesce:` options on `compute`:
6
+
7
+ ```ruby
8
+ compute :available_slots,
9
+ with: [:vendor, :locations, :availability_mode, :current_time],
10
+ call: CheckAvailability,
11
+ cache_ttl: 60, # reuse result for 60 seconds across executions
12
+ coalesce: true # deduplicate concurrent in-flight requests
13
+ ```
14
+
15
+ Both features are opt-in, zero-overhead for unconfigured nodes, and work independently.
16
+
17
+ ---
18
+
19
+ ## Quick Start
20
+
21
+ ```ruby
22
+ require "igniter/node_cache"
23
+
24
+ # In-process memory backend (single Ruby process / single Puma worker)
25
+ Igniter.configure do |c|
26
+ c.node_cache = Igniter::NodeCache::Memory.new
27
+ c.node_coalescing = true # auto-creates a CoalescingLock
28
+ end
29
+ ```
30
+
31
+ Or explicitly:
32
+
33
+ ```ruby
34
+ Igniter::NodeCache.cache = Igniter::NodeCache::Memory.new
35
+ Igniter::NodeCache.coalescing_lock = Igniter::NodeCache::CoalescingLock.new
36
+ ```
37
+
38
+ ---
39
+
40
+ ## Feature 1 — TTL Cache (`cache_ttl:`)
41
+
42
+ Stores the result of a compute node in a shared cache keyed by:
43
+
44
+ ```
45
+ "ttl:{ContractName}:{node_name}:{dep_fingerprint_hex}"
46
+ ```
47
+
48
+ On the next execution, if the deps haven't changed and the TTL hasn't expired, the cached
49
+ value is returned immediately — the executor is never called.
50
+
51
+ ### Usage
52
+
53
+ ```ruby
54
+ class AvailabilityContract < Igniter::Contract
55
+ runner :thread_pool, pool_size: 4
56
+
57
+ define do
58
+ input :vendor_id
59
+ input :zip_code
60
+
61
+ compute :vendor,
62
+ with: :vendor_id,
63
+ call: FindVendor
64
+
65
+ compute :available_slots,
66
+ with: [:vendor, :zip_code],
67
+ call: CheckAvailability,
68
+ cache_ttl: 60 # cache for 60 seconds
69
+ output :available_slots
70
+ end
71
+ end
72
+ ```
73
+
74
+ ### Cache Key
75
+
76
+ The cache key is a 24-hex SHA-256 digest of the serialized dependency values:
77
+
78
+ ```ruby
79
+ dep_hex = Igniter::NodeCache::Fingerprinter.call({ vendor: vendor_obj, zip_code: "10001" })
80
+ key = Igniter::NodeCache::CacheKey.new("AvailabilityContract", :available_slots, dep_hex)
81
+ key.hex # => "ttl:AvailabilityContract:available_slots:4a9f1c0e23ab7d88e4c2"
82
+ ```
83
+
84
+ For stable cross-execution keys, dependency objects should implement `#igniter_fingerprint`
85
+ (see [AR Fingerprinting](#ar-fingerprinting) below).
86
+
87
+ ### Runtime Event
88
+
89
+ When a cached value is returned the resolver emits `:node_ttl_cache_hit`:
90
+
91
+ ```ruby
92
+ contract.execution.events.events.select { |e| e.type == :node_ttl_cache_hit }
93
+ # => [#<Event type=:node_ttl_cache_hit node=:available_slots ...>]
94
+ ```
95
+
96
+ ### Memory Backend
97
+
98
+ `NodeCache::Memory` is a thread-safe in-process store:
99
+
100
+ ```ruby
101
+ cache = Igniter::NodeCache::Memory.new
102
+ cache.stats # => { size: 4, hits: 11, misses: 3 }
103
+ cache.size # => 4
104
+ cache.prune! # removes expired entries (call periodically to reclaim memory)
105
+ cache.clear # removes all entries and resets counters
106
+ ```
107
+
108
+ Entries are automatically expired on `fetch` — no background thread needed.
109
+
110
+ ### Custom / Redis Backend
111
+
112
+ Replace `Memory` with any object implementing `#fetch(key)` and `#store(key, value, ttl:)`:
113
+
114
+ ```ruby
115
+ class RedisNodeCache
116
+ def initialize(redis)
117
+ @redis = redis
118
+ end
119
+
120
+ def fetch(key)
121
+ raw = @redis.get(key.hex)
122
+ raw ? Marshal.load(raw) : nil
123
+ end
124
+
125
+ def store(key, value, ttl:)
126
+ @redis.setex(key.hex, ttl.to_i, Marshal.dump(value))
127
+ value
128
+ end
129
+ end
130
+
131
+ Igniter::NodeCache.cache = RedisNodeCache.new(Redis.new)
132
+ ```
133
+
134
+ A Redis-backed cache shares results across Puma workers and multiple server instances.
135
+
136
+ ---
137
+
138
+ ## Feature 2 — Request Coalescing (`coalesce: true`)
139
+
140
+ When two (or more) executions race to compute the same `coalesce: true` node with
141
+ identical dependency values, only one actually runs — the **leader**. The others become
142
+ **followers** and wait for the leader's result.
143
+
144
+ This eliminates redundant work in auction / multi-vendor scenarios where N vendors submit
145
+ requests for the same lead within milliseconds of each other.
146
+
147
+ ### Usage
148
+
149
+ ```ruby
150
+ compute :available_slots,
151
+ with: [:vendor, :locations],
152
+ call: CheckAvailability,
153
+ cache_ttl: 60,
154
+ coalesce: true # concurrent requests with same deps share one computation
155
+ ```
156
+
157
+ `coalesce: true` requires `cache_ttl:` to be set (the completed result is stored in the
158
+ TTL cache so followers can retrieve it) and `NodeCache.coalescing_lock` to be configured.
159
+
160
+ ### Leader / Follower Flow
161
+
162
+ ```
163
+ Thread A (leader) Thread B (follower)
164
+ ──────────────── ─────────────────────
165
+ acquire(:hex) → :leader acquire(:hex) → :follower
166
+ call CheckAvailability wait(flight) ← blocks
167
+ finish!(:hex, value: result) → unblocked, gets result
168
+ ```
169
+
170
+ If the leader raises an error, `finish!` is called with `error:` — all followers receive
171
+ the error and raise it themselves. A follower that times out (30 s) recomputes independently.
172
+
173
+ ### CoalescingLock API
174
+
175
+ ```ruby
176
+ lock = Igniter::NodeCache::CoalescingLock.new
177
+
178
+ role, flight = lock.acquire(hex) # → [:leader, flight] or [:follower, flight]
179
+ lock.finish!(hex, value: result) # called by leader on success
180
+ lock.finish!(hex, error: exception) # called by leader on failure
181
+ value, error = lock.wait(flight) # called by follower
182
+
183
+ lock.in_flight_count # => 3
184
+ ```
185
+
186
+ ---
187
+
188
+ ## AR Fingerprinting
189
+
190
+ Cache keys are built from the fingerprints of dependency values. For stable keys that
191
+ survive process restarts (required for Redis-backed caches), dependency objects must
192
+ implement `#igniter_fingerprint`.
193
+
194
+ ### `Igniter::Fingerprint` mixin
195
+
196
+ Include in any class whose instances are passed as node dependencies:
197
+
198
+ ```ruby
199
+ class Trade < ApplicationRecord
200
+ include Igniter::Fingerprint
201
+ # default fingerprint: "Trade:42:1712345678" (class:id:updated_at_unix)
202
+ end
203
+ ```
204
+
205
+ The default implementation:
206
+ - **With `updated_at`**: `"ClassName:id:updated_at_unix"` — cache is invalidated on record update.
207
+ - **With `id` only**: `"ClassName:id"`.
208
+ - **Fallback**: `"ClassName:object_id"` — stable within a process, not across restarts.
209
+
210
+ ### Custom fingerprints
211
+
212
+ Override `#igniter_fingerprint` for non-AR objects or custom invalidation logic:
213
+
214
+ ```ruby
215
+ class PricingConfig
216
+ include Igniter::Fingerprint
217
+
218
+ def igniter_fingerprint
219
+ "PricingConfig:#{version}:#{market}"
220
+ end
221
+ end
222
+ ```
223
+
224
+ ### Rails Railtie
225
+
226
+ When using the `igniter-rails` integration, `Igniter::Fingerprint` is automatically
227
+ included in `ApplicationRecord` — no per-model setup required.
228
+
229
+ ---
230
+
231
+ ## `runner` Class Macro
232
+
233
+ The `runner` macro sets the default execution strategy for a contract class, replacing
234
+ the more verbose `run_with`:
235
+
236
+ ```ruby
237
+ class MyContract < Igniter::Contract
238
+ runner :thread_pool, pool_size: 4
239
+
240
+ define do
241
+ # ...
242
+ end
243
+ end
244
+ ```
245
+
246
+ Equivalent to `run_with(runner: :thread_pool, max_workers: 4)`. Accepts `pool_size:` or
247
+ `max_workers:` interchangeably.
248
+
249
+ ---
250
+
251
+ ## Full Example
252
+
253
+ ```ruby
254
+ require "igniter"
255
+ require "igniter/node_cache"
256
+
257
+ Igniter.configure do |c|
258
+ c.node_cache = Igniter::NodeCache::Memory.new
259
+ c.node_coalescing = true
260
+ end
261
+
262
+ class Vendor
263
+ include Igniter::Fingerprint
264
+ attr_reader :id, :updated_at
265
+ def initialize(id) = (@id = id; @updated_at = Time.now)
266
+ end
267
+
268
+ class FetchSlots < Igniter::Executor
269
+ def call(vendor:, zip_code:)
270
+ puts " → computing for vendor=#{vendor.id} zip=#{zip_code}"
271
+ rand(10)
272
+ end
273
+ end
274
+
275
+ class SlotContract < Igniter::Contract
276
+ runner :thread_pool, pool_size: 2
277
+
278
+ define do
279
+ input :vendor
280
+ input :zip_code
281
+ compute :slots, with: [:vendor, :zip_code], call: FetchSlots,
282
+ cache_ttl: 60, coalesce: true
283
+ output :slots
284
+ end
285
+ end
286
+
287
+ vendor = Vendor.new(42)
288
+
289
+ c1 = SlotContract.new(vendor: vendor, zip_code: "10001").resolve
290
+ c2 = SlotContract.new(vendor: vendor, zip_code: "10001").resolve
291
+
292
+ puts c1.result.slots # => 7 (computed)
293
+ puts c2.result.slots # => 7 (TTL cache hit — FetchSlots not called)
294
+ ```
295
+
296
+ ---
297
+
298
+ ## Setup Reference
299
+
300
+ ```ruby
301
+ Igniter.configure do |c|
302
+ c.node_cache = Igniter::NodeCache::Memory.new # or custom Redis backend
303
+ c.node_coalescing = true # auto-creates CoalescingLock
304
+ end
305
+ ```
306
+
307
+ | Option | Default | Description |
308
+ |----------------------------|---------|-------------|
309
+ | `node_cache=` | `nil` | TTL cache backend; `nil` = disabled |
310
+ | `node_coalescing=` | `nil` | `true` creates a `CoalescingLock`; `nil`/`false` = disabled |
311
+
312
+ ---
313
+
314
+ ## Files
315
+
316
+ | File | Purpose |
317
+ |------|---------|
318
+ | `lib/igniter/node_cache.rb` | `CacheKey`, `Memory`, `CoalescingLock`, `Fingerprinter` |
319
+ | `lib/igniter/fingerprint.rb` | `Igniter::Fingerprint` mixin |
320
+ | `lib/igniter/model/compute_node.rb` | `cache_ttl`, `coalesce?` readers |
321
+ | `lib/igniter/runtime/resolver.rb` | TTL cache + coalescing hooks in `resolve_compute` |
322
+ | `lib/igniter.rb` | `node_cache=`, `node_coalescing=` in configure API |
323
+ | `spec/igniter/node_cache_spec.rb` | 42 examples |
324
+ | `examples/elocal_webhook.rb` | Real-world usage — eLocal auction webhook migration |
@@ -0,0 +1,293 @@
1
+ # Proactive Agents — V1
2
+
3
+ ## Concept
4
+
5
+ Standard Igniter agents are **reactive** — they wait for messages and respond.
6
+ A **proactive** agent acts *without being asked*: it polls conditions on a
7
+ schedule, evaluates rules, and fires actions when conditions are met.
8
+
9
+ ```
10
+ Reactive: external message → handler → new state
11
+ Proactive: timer tick → watchers → triggers → actions → new state
12
+ ```
13
+
14
+ `ProactiveAgent` is an experimental base class that adds four DSL keywords and
15
+ a built-in scan lifecycle on top of the standard `Igniter::Agent` API.
16
+
17
+ ---
18
+
19
+ ## Architecture
20
+
21
+ ```
22
+ ProactiveAgent < Igniter::Agent
23
+
24
+ ├── scan_interval — register a recurring :_scan timer
25
+ ├── watch — register a named poll callable
26
+ ├── trigger — register condition + action pair
27
+ └── proactive_initial_state
28
+
29
+ └── :_scan handler (auto-injected into every subclass)
30
+ 1. Call each watcher → build context Hash
31
+ 2. Evaluate each trigger condition(ctx)
32
+ 3. Call action(state:, context:) for truthy conditions
33
+ 4. Append FiredTrigger records, increment scan_count
34
+ ```
35
+
36
+ ### Execution model
37
+
38
+ Every `scan_interval` seconds the timer fires and posts a `:_scan` message.
39
+ The same `:_scan` handler is also callable programmatically — useful in specs
40
+ and for composing proactive agents with other agents.
41
+
42
+ When `active: false` (via `:pause`) the scan cycle is a no-op; timer still
43
+ fires but watchers are not called and triggers are not evaluated.
44
+
45
+ ---
46
+
47
+ ## DSL Reference
48
+
49
+ ### `ProactiveAgent`
50
+
51
+ | Keyword | Description |
52
+ |---|---|
53
+ | `intent "…"` | Human-readable mission string (metadata, shown in `:status`) |
54
+ | `scan_interval N` | Register a recurring timer every N seconds |
55
+ | `watch :name, poll: callable` | Named watcher; callable returns current reading |
56
+ | `trigger :name, condition:, action:` | Register a conditional action |
57
+ | `proactive_initial_state extra` | Set initial state (merges ProactiveAgent defaults) |
58
+
59
+ ### Built-in handlers (injected into every subclass)
60
+
61
+ | Handler | Type | Description |
62
+ |---|---|---|
63
+ | `:_scan` | state-mutating | Run one scan cycle |
64
+ | `:pause` | state-mutating | Suspend trigger evaluation (active: false) |
65
+ | `:resume` | state-mutating | Resume trigger evaluation (active: true) |
66
+ | `:status` | sync query → `Status` | Counts, intent, watcher/trigger names |
67
+ | `:context` | sync query → Hash | Last context snapshot |
68
+ | `:trigger_history` | sync query → Array | Up to 100 most recent `FiredTrigger` records |
69
+
70
+ ---
71
+
72
+ ## Quick start
73
+
74
+ ```ruby
75
+ require "igniter/agents/proactive_agent"
76
+
77
+ class ServerTempMonitor < Igniter::Agents::ProactiveAgent
78
+ intent "Alert when server room temperature exceeds safe range"
79
+
80
+ scan_interval 10.0 # check every 10 seconds
81
+
82
+ watch :temp_c, poll: -> { SensorAPI.read_temp }
83
+
84
+ trigger :overheating,
85
+ condition: ->(ctx) { ctx[:temp_c].to_f > 30 },
86
+ action: ->(state:, context:) {
87
+ Notifier.alert("Server room temp: #{context[:temp_c]}°C")
88
+ state.merge(last_alert_at: Time.now)
89
+ }
90
+
91
+ proactive_initial_state last_alert_at: nil
92
+ end
93
+
94
+ ref = ServerTempMonitor.start
95
+ status = ref.call(:status)
96
+ # => Status(active: true, scan_count: 0, intent: "Alert when …", …)
97
+ ```
98
+
99
+ ---
100
+
101
+ ## Subclasses — AlertAgent and HealthCheckAgent
102
+
103
+ Two production-ready proactive agents are included in the stdlib:
104
+
105
+ ```
106
+ require "igniter/agents"
107
+ ```
108
+
109
+ ### `AlertAgent`
110
+
111
+ Threshold-based numeric monitoring with a concise DSL:
112
+
113
+ ```ruby
114
+ class ApiMonitor < Igniter::Agents::AlertAgent
115
+ intent "Watch error rate and latency"
116
+ scan_interval 15.0
117
+
118
+ monitor :error_rate, source: -> { Metrics.error_rate }
119
+ monitor :p99_ms, source: -> { Metrics.p99_latency }
120
+
121
+ threshold :error_rate, above: 0.05 # >5% errors
122
+ threshold :p99_ms, above: 800 # >800ms p99
123
+ threshold :p99_ms, below: 1 # ghost: no traffic at all
124
+
125
+ proactive_initial_state alerts: [], silenced: false
126
+ end
127
+
128
+ ref = ApiMonitor.start
129
+ ref.send(:silence) # suppress new alerts
130
+ alerts = ref.call(:alerts) # => Array<AlertRecord>
131
+ ref.send(:clear_alerts)
132
+ ```
133
+
134
+ **AlertRecord fields**: `metric`, `value`, `kind` (`:above`/`:below`),
135
+ `threshold`, `fired_at`.
136
+
137
+ ### `HealthCheckAgent`
138
+
139
+ Service liveness polling with automatic transition detection:
140
+
141
+ ```ruby
142
+ class InfraHealth < Igniter::Agents::HealthCheckAgent
143
+ intent "Monitor database and cache"
144
+ scan_interval 30.0
145
+
146
+ # poll returns truthy = healthy, falsy / raises = unhealthy
147
+ check :database, poll: -> { DB.ping }
148
+ check :cache, poll: -> { Redis.current.ping == "PONG" }
149
+
150
+ proactive_initial_state health: {}, transitions: []
151
+ end
152
+
153
+ ref = InfraHealth.start
154
+ health = ref.call(:health) # => { database: :healthy, cache: :unhealthy }
155
+ all_ok = ref.call(:all_healthy) # => false
156
+ transitions = ref.call(:transitions) # => [Transition(cache: unknown→unhealthy)]
157
+ ```
158
+
159
+ Transitions are only recorded when status **changes** — no duplicate events for
160
+ persistently unhealthy services.
161
+
162
+ **Transition fields**: `service`, `from`, `to`, `occurred_at`.
163
+
164
+ ---
165
+
166
+ ## Building your own ProactiveAgent subclass
167
+
168
+ ### Pattern: layering reactive and proactive handlers
169
+
170
+ ```ruby
171
+ class StockWatcher < Igniter::Agents::ProactiveAgent
172
+ intent "Monitor stock price and fire when it crosses buy/sell thresholds"
173
+
174
+ scan_interval 60.0
175
+
176
+ watch :price, poll: -> { FinanceAPI.last_price("AAPL") }
177
+
178
+ trigger :buy_signal,
179
+ condition: ->(ctx) { ctx[:price].to_f < 150 },
180
+ action: ->(state:, context:) {
181
+ state.merge(signals: state[:signals] + [{ type: :buy, price: context[:price], at: Time.now }])
182
+ }
183
+
184
+ proactive_initial_state signals: []
185
+
186
+ # Reactive handler for manual context override (e.g. in tests)
187
+ on :inject_price do |state:, payload:|
188
+ state.merge(context: state[:context].merge(price: payload[:price]))
189
+ end
190
+
191
+ on :signals do |state:, **|
192
+ state[:signals].dup
193
+ end
194
+ end
195
+ ```
196
+
197
+ ### Pattern: re-injecting handlers in a concrete subclass
198
+
199
+ Because `Agent.inherited` resets `@handlers`, any handlers defined in a
200
+ parent class (like `AlertAgent`) are NOT automatically present in
201
+ `Class.new(AlertAgent)` (used in tests or further subclasses).
202
+
203
+ Both `AlertAgent` and `HealthCheckAgent` override `inherited` and call
204
+ `inject_*_handlers!(subclass)` to ensure their handlers are always present.
205
+ Follow the same pattern when building your own concrete subclass:
206
+
207
+ ```ruby
208
+ class MyAgent < Igniter::Agents::ProactiveAgent
209
+ def self.inherited(subclass)
210
+ super # ProactiveAgent.inherited injects :_scan etc.
211
+ inject_my_handlers!(subclass)
212
+ end
213
+
214
+ private_class_method def self.inject_my_handlers!(klass)
215
+ klass.on(:my_query) { |state:, **| state[:my_data].dup }
216
+ end
217
+
218
+ on :my_query do |state:, **|
219
+ state[:my_data].dup
220
+ end
221
+ end
222
+ ```
223
+
224
+ ### Testing: drive scans without a real timer
225
+
226
+ ```ruby
227
+ RSpec.describe MyAgent do
228
+ let(:h) { ->(type, state) { MyAgent.handlers[type].call(state: state, payload: {}) } }
229
+
230
+ def base_state(extra = {})
231
+ { active: true, context: {}, scan_count: 0,
232
+ last_scan_at: nil, trigger_history: [] }.merge(extra)
233
+ end
234
+
235
+ it "fires trigger when condition is met" do
236
+ # Call :_scan directly — no timer, no threads
237
+ result = h.call(:_scan, base_state)
238
+ expect(result[:trigger_history]).not_to be_empty
239
+ end
240
+ end
241
+ ```
242
+
243
+ ---
244
+
245
+ ## Companion example
246
+
247
+ `examples/companion/proactive/` shows three proactive agents in the context of
248
+ the voice assistant companion application:
249
+
250
+ | File | Agent | Mission |
251
+ |---|---|---|
252
+ | `conversation_nudge_agent.rb` | `ConversationNudgeAgent` | Detect silence and topic stagnation; propose conversation nudges |
253
+ | `system_watch_agent.rb` | `SystemAlertAgent` / `DependencyHealthAgent` | Monitor API metrics and service liveness |
254
+ | `demo.rb` | All four agents | Self-contained, runnable demonstration |
255
+
256
+ ```bash
257
+ ruby examples/companion/proactive/demo.rb
258
+ ```
259
+
260
+ ---
261
+
262
+ ## API quick reference
263
+
264
+ ```ruby
265
+ # Base class DSL
266
+ class MyAgent < Igniter::Agents::ProactiveAgent
267
+ intent "…"
268
+ scan_interval 5.0
269
+ watch :metric, poll: -> { source }
270
+ trigger :name, condition: ->(ctx) { … }, action: ->(state:, context:) { … }
271
+ proactive_initial_state key: default_value
272
+ end
273
+
274
+ # Runtime
275
+ ref = MyAgent.start
276
+ ref.send(:pause) # suspend reactions
277
+ ref.send(:resume) # resume
278
+ ref.call(:status) # => Status struct
279
+ ref.call(:context) # => { metric: last_reading, … }
280
+ ref.call(:trigger_history) # => [FiredTrigger, …]
281
+
282
+ # AlertAgent
283
+ ref.send(:silence) # suppress new alerts
284
+ ref.send(:unsilence)
285
+ ref.call(:alerts) # => [AlertRecord, …]
286
+ ref.send(:clear_alerts)
287
+
288
+ # HealthCheckAgent
289
+ ref.call(:health) # => { service: :healthy/:unhealthy }
290
+ ref.call(:all_healthy) # => true | false
291
+ ref.call(:transitions) # => [Transition, …]
292
+ ref.send(:reset)
293
+ ```