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