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
data/docs/SERVER_V1.md CHANGED
@@ -66,6 +66,9 @@ Igniter::Server.configure do |c|
66
66
  c.host = "0.0.0.0" # bind address (default: "0.0.0.0")
67
67
  c.port = 4567 # TCP port (default: 4567)
68
68
  c.store = my_store # execution store for distributed contracts
69
+ c.log_format = :text # :text (default) or :json — structured JSON for Loki/ELK
70
+ c.drain_timeout = 30 # seconds to drain in-flight requests on SIGTERM (default: 30)
71
+ c.metrics_collector = Igniter::Metrics::Collector.new # enable Prometheus metrics
69
72
  c.register "Name", MyClass # register a contract
70
73
  c.contracts = { # bulk registration
71
74
  "ContractA" => ContractA,
@@ -145,9 +148,90 @@ Returns `404` if the execution is not found in the configured store.
145
148
 
146
149
  ---
147
150
 
151
+ ### `GET /v1/live`
152
+
153
+ Kubernetes **liveness** probe. Always returns `200 OK`. If this endpoint fails, the pod should be restarted.
154
+
155
+ **Response:**
156
+ ```json
157
+ { "status": "alive", "pid": 12345 }
158
+ ```
159
+
160
+ ---
161
+
162
+ ### `GET /v1/ready`
163
+
164
+ Kubernetes **readiness** probe. Returns `200` when the server can accept traffic, `503` when it cannot (no contracts registered or store unreachable).
165
+
166
+ **Response (ready):**
167
+ ```json
168
+ { "status": "ready", "checks": { "store": "ok", "contracts": "ok" } }
169
+ ```
170
+
171
+ **Response (not ready, 503):**
172
+ ```json
173
+ { "status": "not_ready", "checks": { "store": "ok", "contracts": "no_contracts_registered" } }
174
+ ```
175
+
176
+ ---
177
+
178
+ ### `GET /v1/metrics`
179
+
180
+ Prometheus text format metrics (exposition format 0.0.4). Returns `501` if no `metrics_collector` is configured.
181
+
182
+ **Content-Type:** `text/plain; version=0.0.4; charset=utf-8`
183
+
184
+ **Metrics exposed:**
185
+
186
+ | Metric | Type | Labels | Description |
187
+ |--------|------|--------|-------------|
188
+ | `igniter_executions_total` | counter | `graph`, `status` | Contract executions completed |
189
+ | `igniter_execution_duration_seconds` | histogram | `graph` | Execution wall-clock duration |
190
+ | `igniter_http_requests_total` | counter | `method`, `path`, `status` | HTTP requests received |
191
+ | `igniter_http_request_duration_seconds` | histogram | `method`, `path`, `status` | HTTP request processing duration |
192
+ | `igniter_pending_executions` | gauge | `graph` | Executions currently in pending state in the store |
193
+
194
+ Dynamic path segments are collapsed to avoid high cardinality (e.g. `/v1/contracts/MyContract/execute` → `/v1/contracts/:name/execute`).
195
+
196
+ **Enable metrics:**
197
+ ```ruby
198
+ require "igniter/metrics"
199
+
200
+ Igniter::Server.configure do |c|
201
+ c.metrics_collector = Igniter::Metrics::Collector.new
202
+ end
203
+ ```
204
+
205
+ ---
206
+
207
+ ### `GET /v1/manifest`
208
+
209
+ Returns a JSON description of this peer for use by the Igniter Mesh. See [MESH_V1.md](MESH_V1.md).
210
+
211
+ **Response:**
212
+ ```json
213
+ {
214
+ "peer_name": "orders-node",
215
+ "capabilities": ["orders", "inventory"],
216
+ "contracts": ["ProcessOrder"],
217
+ "url": "http://0.0.0.0:4567"
218
+ }
219
+ ```
220
+
221
+ Configure `peer_name` and `peer_capabilities` on `Igniter::Server`:
222
+
223
+ ```ruby
224
+ Igniter::Server.configure do |c|
225
+ c.peer_name = "orders-node"
226
+ c.peer_capabilities = %i[orders inventory]
227
+ end
228
+ ```
229
+
230
+ ---
231
+
148
232
  ### `GET /v1/health`
149
233
 
150
- Health check endpoint.
234
+ General health check (human-readable, not a K8s probe).
151
235
 
152
236
  **Response:**
153
237
  ```json
@@ -212,6 +296,11 @@ an `Igniter::ResolutionError` is raised and propagates like any other node failu
212
296
  **Note:** `require "igniter/server"` is required to load `Igniter::Server::Client`.
213
297
  Without it, any contract with a `remote:` node will raise at resolution time.
214
298
 
299
+ ### Mesh routing (capability / pinned)
300
+
301
+ The `remote:` keyword also supports two mesh routing modes via `capability:` and `pinned_to:`
302
+ instead of a hard-coded `node:` URL. See [MESH_V1.md](MESH_V1.md) for the full reference.
303
+
215
304
  ---
216
305
 
217
306
  ## HTTP Client
@@ -268,6 +357,116 @@ not at the first HTTP call.
268
357
 
269
358
  ---
270
359
 
360
+ ## Kubernetes Deployment
361
+
362
+ ### Deployment manifest
363
+
364
+ ```yaml
365
+ apiVersion: apps/v1
366
+ kind: Deployment
367
+ metadata:
368
+ name: igniter-server
369
+ spec:
370
+ replicas: 3
371
+ template:
372
+ spec:
373
+ terminationGracePeriodSeconds: 60 # must be > drain_timeout
374
+ containers:
375
+ - name: igniter
376
+ image: my-org/igniter-app:latest
377
+ ports:
378
+ - containerPort: 4567
379
+ env:
380
+ - name: REDIS_URL
381
+ valueFrom:
382
+ secretKeyRef: { name: redis-secret, key: url }
383
+ livenessProbe:
384
+ httpGet: { path: /v1/live, port: 4567 }
385
+ initialDelaySeconds: 5
386
+ periodSeconds: 10
387
+ readinessProbe:
388
+ httpGet: { path: /v1/ready, port: 4567 }
389
+ initialDelaySeconds: 3
390
+ periodSeconds: 5
391
+ lifecycle:
392
+ preStop:
393
+ exec:
394
+ command: ["/bin/sh", "-c", "sleep 5"] # give k8s time to drain
395
+ ```
396
+
397
+ ### Server configuration for K8s
398
+
399
+ ```ruby
400
+ require "igniter/server"
401
+ require "igniter/metrics"
402
+
403
+ Igniter::Server.configure do |c|
404
+ c.host = "0.0.0.0"
405
+ c.port = 4567
406
+ c.log_format = :json # structured logs → stdout → Loki/CloudWatch
407
+ c.drain_timeout = 30 # seconds, must be < terminationGracePeriodSeconds
408
+ c.metrics_collector = Igniter::Metrics::Collector.new
409
+ c.store = Igniter::Runtime::Stores::RedisStore.new(
410
+ redis: Redis.new(url: ENV.fetch("REDIS_URL")),
411
+ namespace: "igniter:prod"
412
+ )
413
+ c.register "MyContract", MyContract
414
+ end
415
+
416
+ Igniter::Server.start
417
+ ```
418
+
419
+ ### Prometheus scraping (prometheus.yml)
420
+
421
+ ```yaml
422
+ scrape_configs:
423
+ - job_name: igniter
424
+ static_configs:
425
+ - targets: ["igniter-service:4567"]
426
+ metrics_path: /v1/metrics
427
+ ```
428
+
429
+ ---
430
+
431
+ ## Structured Logging
432
+
433
+ igniter-server logs every request and lifecycle event to `$stdout`.
434
+
435
+ **Text format** (default, `:text`):
436
+ ```
437
+ [2026-04-10T12:00:00Z] INFO igniter-server started host=0.0.0.0 port=4567 pid=1
438
+ [2026-04-10T12:00:01Z] INFO POST /v1/contracts/MyContract/execute status=200
439
+ [2026-04-10T12:00:02Z] INFO SIGTERM received — draining drain_timeout=30 pid=1
440
+ ```
441
+
442
+ **JSON format** (`:json`) — one JSON object per line, compatible with Loki, ELK, CloudWatch Logs:
443
+ ```json
444
+ {"time":"2026-04-10T12:00:00.123Z","level":"INFO","msg":"igniter-server started","host":"0.0.0.0","port":4567,"pid":1}
445
+ {"time":"2026-04-10T12:00:01.456Z","level":"INFO","msg":"POST /v1/contracts/MyContract/execute","status":200}
446
+ ```
447
+
448
+ Configure via:
449
+ ```ruby
450
+ Igniter::Server.configure { |c| c.log_format = :json }
451
+ ```
452
+
453
+ ---
454
+
455
+ ## Graceful Shutdown
456
+
457
+ On `SIGTERM`, the server:
458
+ 1. Stops accepting new connections
459
+ 2. Waits up to `drain_timeout` seconds for in-flight requests to complete
460
+ 3. Exits cleanly
461
+
462
+ ```ruby
463
+ Igniter::Server.configure { |c| c.drain_timeout = 30 }
464
+ ```
465
+
466
+ Set `terminationGracePeriodSeconds` in your Kubernetes Deployment to a value greater than `drain_timeout` (e.g. 60s) to give the server enough time to drain before the pod is force-killed.
467
+
468
+ ---
469
+
271
470
  ## Security
272
471
 
273
472
  igniter-server ships with no built-in authentication. For production deployments:
data/docs/SKILLS_V1.md ADDED
@@ -0,0 +1,213 @@
1
+ # Igniter Skills — V1
2
+
3
+ ## Overview
4
+
5
+ `Igniter::Skill` is a composable unit of agent capability — the bridge between
6
+ atomic Tools and full autonomous Agents.
7
+
8
+ | | Tool | Skill | Agent |
9
+ |---|---|---|---|
10
+ | **Purpose** | Single operation | Multi-step task | Long-running process |
11
+ | **LLM inside** | No | Yes (own loop) | Yes (own loop + mailbox) |
12
+ | **Discoverable** | ✓ | ✓ | — |
13
+ | **Capability guard** | ✓ | ✓ | — |
14
+ | **Registered in ToolRegistry** | ✓ | ✓ | — |
15
+ | **Used in `tools` DSL** | ✓ | ✓ | — |
16
+ | **Duration** | ms | s–min | unbounded |
17
+
18
+ ## Key Insight
19
+
20
+ From the **parent agent's perspective**, a Skill looks identical to a Tool:
21
+ it has a `name`, `description`, parameter schema, and a `call_with_capability_check!`
22
+ interface. The parent LLM cannot tell the difference. This enables **hierarchical agents**
23
+ where each level delegates to the next.
24
+
25
+ ```
26
+ ChatExecutor (parent)
27
+ tools: [TimeTool, WeatherTool, ResearchSkill, WriteCodeSkill]
28
+
29
+ ResearchSkill (sub-agent)
30
+ tools: [SearchWebTool, ReadUrlTool]
31
+ Runs own LLM + tool loop internally
32
+ ```
33
+
34
+ ## Defining a Skill
35
+
36
+ ```ruby
37
+ class ResearchSkill < Igniter::Skill
38
+ # ── Discovery interface (same as Tool) ──
39
+ description "Research a topic by searching and synthesizing multiple sources"
40
+
41
+ param :topic, type: :string, required: true,
42
+ desc: "The subject to research"
43
+ param :depth, type: :string, required: false, default: "brief",
44
+ desc: "brief | detailed"
45
+
46
+ requires_capability :network
47
+
48
+ # ── Agentic implementation (LLM::Executor DSL) ──
49
+ provider :anthropic
50
+ model "claude-sonnet-4-6"
51
+ system_prompt "You are a research assistant. Be concise and accurate."
52
+
53
+ tools SearchWebTool, ReadUrlTool # skill's own sub-tools
54
+ max_tool_iterations 8
55
+
56
+ def call(topic:, depth: "brief")
57
+ instruction = depth == "detailed" ? "comprehensive" : "concise 2-3 sentence"
58
+ complete("Research this and return a #{instruction} summary: #{topic}")
59
+ end
60
+ end
61
+ ```
62
+
63
+ ## Hierarchy Example: ChatExecutor with Skills
64
+
65
+ ```ruby
66
+ class ChatExecutor < Igniter::LLM::Executor
67
+ provider :ollama
68
+ model "llama3.1:8b"
69
+ capabilities :network, :storage # controls which tools/skills may run
70
+
71
+ tools TimeTool, # instant lookup, no LLM needed
72
+ WeatherTool, # instant lookup, no LLM needed
73
+ SaveNoteTool, # atomic storage write
74
+ GetNotesTool, # atomic storage read
75
+ ResearchSkill, # → triggers its own LLM loop when called
76
+ RemindMeSkill # → triggers its own LLM loop when called
77
+
78
+ max_tool_iterations 6
79
+
80
+ def call(message:, conversation_history:, intent:)
81
+ ctx = build_context(conversation_history, intent)
82
+ complete(message, context: ctx)
83
+ # The auto loop transparently selects the right tool or skill per turn.
84
+ # When ResearchSkill is called, it runs its own sub-loop before returning.
85
+ end
86
+ end
87
+ ```
88
+
89
+ ## Schema Generation
90
+
91
+ Skills and Tools produce identical schema formats — providers cannot tell them apart.
92
+
93
+ ```ruby
94
+ ResearchSkill.tool_name # => "research_skill"
95
+ ResearchSkill.to_schema # => { name:, description:, parameters: {...} }
96
+ ResearchSkill.to_schema(:anthropic) # => { name:, description:, input_schema: {...} }
97
+ ResearchSkill.to_schema(:openai) # => { type: "function", function: {...} }
98
+ ```
99
+
100
+ ## ToolRegistry
101
+
102
+ Skills register the same way as Tools:
103
+
104
+ ```ruby
105
+ Igniter::ToolRegistry.register(
106
+ TimeTool, WeatherTool, # tools
107
+ ResearchSkill, # skill — registered exactly like a tool
108
+ )
109
+
110
+ Igniter::ToolRegistry.tools_for(capabilities: [:network])
111
+ # => [WeatherTool, ResearchSkill] (TimeTool has no cap requirement → always included)
112
+
113
+ Igniter::ToolRegistry.schemas(:anthropic, capabilities: [:network, :storage])
114
+ ```
115
+
116
+ ## Capability Guard
117
+
118
+ Same `call_with_capability_check!` interface as Tool. `CapabilityError` is the same class:
119
+
120
+ ```ruby
121
+ Igniter::Skill::CapabilityError == Igniter::Tool::CapabilityError # => true
122
+
123
+ skill = ResearchSkill.new
124
+ skill.call_with_capability_check!(allowed_capabilities: [], topic: "AI")
125
+ # => raises Igniter::Tool::CapabilityError: "research_skill" requires [:network]
126
+ ```
127
+
128
+ ## Inheritance
129
+
130
+ A Skill inherits BOTH the Discoverable DSL AND the LLM::Executor config:
131
+
132
+ ```ruby
133
+ class BaseResearcher < Igniter::Skill
134
+ provider :anthropic
135
+ model "claude-sonnet-4-6"
136
+ requires_capability :network
137
+ end
138
+
139
+ class DeepResearchSkill < BaseResearcher
140
+ description "Exhaustive multi-source research with citations"
141
+ param :topic, type: :string, required: true, desc: "Topic"
142
+ max_tool_iterations 20
143
+ # inherits provider, model, requires_capability from BaseResearcher
144
+ end
145
+ ```
146
+
147
+ ## Tool::Discoverable
148
+
149
+ Both `Tool` and `Skill` include `Igniter::Tool::Discoverable`, which provides:
150
+
151
+ | Method | Description |
152
+ |--------|-------------|
153
+ | `.description(text)` | Set LLM-facing description |
154
+ | `.param(name, type:, ...)` | Declare a parameter |
155
+ | `.requires_capability(*caps)` | Declare required capabilities |
156
+ | `.tool_name` | Auto-derived snake_case name |
157
+ | `.to_schema(provider)` | Generate LLM tool schema |
158
+ | `.tool_params` | Array of declared params |
159
+ | `.required_capabilities` | Array of required caps |
160
+ | `#call_with_capability_check!(...)` | Guard + invoke `#call` |
161
+
162
+ ## When to Use Tool vs Skill vs Agent
163
+
164
+ **Use a Tool when:**
165
+ - Operation is atomic and deterministic
166
+ - No LLM reasoning needed internally
167
+ - Response comes back in milliseconds
168
+ - Examples: `TimeTool`, `SaveNoteTool`, `WebhookTool`
169
+
170
+ **Use a Skill when:**
171
+ - Task requires multi-step reasoning or tool orchestration
172
+ - Output benefits from LLM synthesis (not just data retrieval)
173
+ - Task runs in seconds (not unbounded)
174
+ - Parent agent should treat it as a single callable unit
175
+ - Examples: `ResearchSkill`, `RemindMeSkill`, `WriteCodeSkill`, `TranslateDocumentSkill`
176
+
177
+ **Use an Agent when:**
178
+ - Long-running process with its own lifecycle (mailbox, supervisor)
179
+ - Needs to handle events asynchronously
180
+ - Maintains complex persistent state
181
+ - Examples: monitoring agent, background indexer, event-driven workflow
182
+
183
+ ## Companion Example
184
+
185
+ The Companion voice assistant uses both tools and skills:
186
+
187
+ ```
188
+ ChatExecutor
189
+ ├── TimeTool [tool] → "what time is it?"
190
+ ├── WeatherTool [tool] → "weather in Moscow?"
191
+ ├── SaveNoteTool [tool] → persist a note
192
+ ├── GetNotesTool [tool] → recall a note
193
+ ├── ResearchSkill [skill] → "explain how Raft consensus works"
194
+ └── RemindMeSkill [skill] → "remind me to call Alice tomorrow at 3pm"
195
+ ```
196
+
197
+ Skills in Companion:
198
+ - `ResearchSkill` — keyword search + LLM synthesis (uses `SaveNoteTool` to persist findings)
199
+ - `RemindMeSkill` — NL parsing + structured save (uses `TimeTool` + `SaveNoteTool`)
200
+ When Consensus cluster is active, notes survive node failures automatically.
201
+
202
+ ## Key Files
203
+
204
+ | File | Description |
205
+ |------|-------------|
206
+ | `lib/igniter/skill.rb` | Skill base class |
207
+ | `lib/igniter/tool/discoverable.rb` | Shared DSL (Tool + Skill) |
208
+ | `lib/igniter/tool.rb` | Tool base class |
209
+ | `lib/igniter/tool_registry.rb` | Registry for Tool + Skill |
210
+ | `spec/igniter/skill_spec.rb` | 30+ examples |
211
+ | `examples/companion/skills/research_skill.rb` | ResearchSkill demo |
212
+ | `examples/companion/skills/remind_me_skill.rb` | RemindMeSkill demo |
213
+ | `docs/TOOLS_V1.md` | Tool system docs |
@@ -7,12 +7,17 @@ Igniter ships with reference execution stores for:
7
7
  - ActiveRecord-style persistence
8
8
  - Redis-style persistence
9
9
 
10
- All stores implement the same minimal protocol:
11
-
12
- - `save(snapshot)`
13
- - `fetch(execution_id)`
14
- - `delete(execution_id)`
15
- - `exist?(execution_id)`
10
+ All stores implement the same protocol:
11
+
12
+ | Method | Description |
13
+ |--------|-------------|
14
+ | `save(snapshot, correlation: nil, graph: nil)` | Persist a snapshot; build secondary indexes for query |
15
+ | `fetch(execution_id)` | Load a snapshot by ID; raises on missing |
16
+ | `delete(execution_id)` | Remove a snapshot and clean up indexes |
17
+ | `exist?(execution_id)` | Check existence without raising |
18
+ | `find_by_correlation(graph:, correlation:)` | Find execution_id by correlation hash |
19
+ | `list_all(graph: nil)` | All execution_ids, optionally filtered by graph name |
20
+ | `list_pending(graph: nil)` | Execution_ids that have at least one node in `:pending` state |
16
21
 
17
22
  ## Memory Store
18
23
 
@@ -75,22 +80,45 @@ Igniter.execution_store = Igniter::Runtime::Stores::ActiveRecordStore.new(
75
80
 
76
81
  ## Redis Store
77
82
 
78
- Expected client protocol:
83
+ `RedisStore` maintains secondary indexes so it can answer all query API calls efficiently:
84
+
85
+ | Key pattern | Redis type | Purpose |
86
+ |-------------|-----------|---------|
87
+ | `{ns}:{execution_id}` | String | Serialized snapshot JSON |
88
+ | `{ns}:all` | Set | All execution IDs |
89
+ | `{ns}:graph:{name}` | Set | Execution IDs for one graph |
90
+ | `{ns}:corr:{graph}` | Hash | `JSON(sorted_correlation)` → execution_id |
79
91
 
80
- - `set(key, value)`
81
- - `get(key)`
82
- - `del(key)`
83
- - `exists?(key)`
92
+ The client must support these Redis commands:
93
+ `set`, `get`, `del`, `exists?`, `sadd`, `srem`, `smembers`, `hset`, `hget`.
84
94
 
85
- Example configuration:
95
+ The standard [`redis` gem](https://github.com/redis/redis-rb) satisfies this interface.
86
96
 
87
97
  ```ruby
88
98
  redis = Redis.new(url: ENV.fetch("REDIS_URL"))
89
99
 
90
100
  Igniter.execution_store = Igniter::Runtime::Stores::RedisStore.new(
91
101
  redis: redis,
92
- namespace: "igniter:executions"
102
+ namespace: "igniter:executions" # optional, default shown
103
+ )
104
+ ```
105
+
106
+ **Query API:**
107
+
108
+ ```ruby
109
+ store = Igniter::Runtime::Stores::RedisStore.new(redis: redis)
110
+
111
+ # Find a pending execution by correlation
112
+ execution_id = store.find_by_correlation(
113
+ graph: "OrderContract",
114
+ correlation: { order_id: "o-42" }
93
115
  )
116
+
117
+ # List all executions for a graph
118
+ ids = store.list_all(graph: "OrderContract")
119
+
120
+ # List only pending executions (O(n) scan — acceptable for moderate volumes)
121
+ pending_ids = store.list_pending(graph: "OrderContract")
94
122
  ```
95
123
 
96
124
  ## Worker Flow
@@ -0,0 +1,174 @@
1
+ # Temporal Contracts — v1
2
+
3
+ Temporal contracts make time an explicit, first-class input so that every execution is
4
+ fully reproducible. By supplying the original timestamp you can replay any historical
5
+ computation and get the identical result — even months later.
6
+
7
+ ## Quick Start
8
+
9
+ ```ruby
10
+ require "igniter/temporal"
11
+
12
+ class TaxRateContract < Igniter::Contract
13
+ include Igniter::Temporal
14
+
15
+ define do
16
+ input :country
17
+
18
+ # `as_of` is injected automatically (default: Time.now)
19
+ temporal_compute :rate, depends_on: :country do |country:, as_of:|
20
+ HISTORICAL_RATES.dig(country, as_of.year) || 0.0
21
+ end
22
+
23
+ output :rate
24
+ end
25
+ end
26
+
27
+ # Current rate — as_of defaults to Time.now
28
+ TaxRateContract.new(country: "UA").result.rate
29
+ # => 0.22
30
+
31
+ # Reproduce the 2024 rate exactly
32
+ TaxRateContract.new(country: "UA", as_of: Time.new(2024, 1, 1)).result.rate
33
+ # => 0.20
34
+ ```
35
+
36
+ ## How It Works
37
+
38
+ Including `Igniter::Temporal` overrides `define` to inject one extra input before the
39
+ user block runs:
40
+
41
+ ```ruby
42
+ input :as_of, default: -> { Time.now }
43
+ ```
44
+
45
+ The default is a Proc so it is called freshly at execution time — no stale timestamps
46
+ if the contract class is reused across requests.
47
+
48
+ `temporal_compute` is a DSL helper that behaves identically to `compute` but automatically
49
+ appends `:as_of` to the `depends_on` list.
50
+
51
+ ## Module Inclusion
52
+
53
+ ```ruby
54
+ class MyContract < Igniter::Contract
55
+ include Igniter::Temporal
56
+ # ...
57
+ end
58
+
59
+ MyContract.temporal? # => true
60
+ ```
61
+
62
+ Plain contracts without the mixin return `false` from `respond_to?(:temporal?)`.
63
+
64
+ ## `as_of` Input
65
+
66
+ The injected input behaves like any other input:
67
+
68
+ - **Default**: `-> { Time.now }` — evaluated at execution time.
69
+ - **Override**: pass `as_of:` when constructing the contract.
70
+ - **Type**: any `Time`-like object is accepted (no type constraint).
71
+
72
+ ```ruby
73
+ # Explicit as_of
74
+ contract = TaxRateContract.new(country: "UA", as_of: Time.new(2024, 6, 1))
75
+ contract.result.rate # => 0.20 (2024 rate)
76
+
77
+ # Default as_of — Time.now
78
+ contract = TaxRateContract.new(country: "UA")
79
+ contract.result.rate # => current rate
80
+ ```
81
+
82
+ ## `temporal_compute` DSL
83
+
84
+ `temporal_compute` is equivalent to:
85
+
86
+ ```ruby
87
+ compute :name, depends_on: [:original_deps..., :as_of], ...
88
+ ```
89
+
90
+ The block receives `as_of:` as a keyword argument alongside the declared dependencies:
91
+
92
+ ```ruby
93
+ temporal_compute :result, depends_on: [:amount, :country] do |amount:, country:, as_of:|
94
+ # as_of is available automatically
95
+ rate = RateTable.lookup(country: country, year: as_of.year)
96
+ amount * rate
97
+ end
98
+ ```
99
+
100
+ You can also mix `temporal_compute` with regular `compute` in the same contract —
101
+ only the nodes that need time-awareness need to use `temporal_compute`.
102
+
103
+ ## Class-Based Executors
104
+
105
+ For class-based compute nodes in temporal contracts, inherit from
106
+ `Igniter::Temporal::Executor`. This signals intent and ensures `as_of:` is always
107
+ passed as a keyword argument:
108
+
109
+ ```ruby
110
+ class TaxRateExecutor < Igniter::Temporal::Executor
111
+ def call(country:, as_of:)
112
+ HISTORICAL_RATES.dig(country, as_of.year) || 0.0
113
+ end
114
+ end
115
+
116
+ class TaxRateContract < Igniter::Contract
117
+ include Igniter::Temporal
118
+
119
+ define do
120
+ input :country
121
+ temporal_compute :rate, depends_on: :country, call: TaxRateExecutor
122
+ output :rate
123
+ end
124
+ end
125
+ ```
126
+
127
+ `Igniter::Temporal::Executor` is a plain subclass of `Igniter::Executor` — it imposes
128
+ no additional behaviour, it is purely documentary.
129
+
130
+ ## Reproducibility Pattern
131
+
132
+ The key property of temporal contracts: given the same inputs **and** the same `as_of`,
133
+ the output is always identical. Store the `as_of` alongside your results and you can
134
+ replay any historical computation:
135
+
136
+ ```ruby
137
+ # At billing time
138
+ result = InvoiceContract.new(customer_id: "c1").result
139
+ as_of_used = result.states[:as_of].value # the Time.now captured during execution
140
+ invoice_id = persist(result, as_of: as_of_used)
141
+
142
+ # Audit replay 6 months later
143
+ audit = InvoiceContract.new(customer_id: "c1", as_of: as_of_used).result
144
+ audit.total == result.total # => true
145
+ ```
146
+
147
+ ## Combining with Other Extensions
148
+
149
+ Temporal contracts compose with every other Igniter feature:
150
+
151
+ ```ruby
152
+ require "igniter/temporal"
153
+ require "igniter/extensions/content_addressing"
154
+
155
+ class TaxCalculator < Igniter::Temporal::Executor
156
+ pure # content-addressed — same country+as_of → cached result
157
+ fingerprint "tax_v2"
158
+
159
+ def call(country:, as_of:)
160
+ HISTORICAL_RATES.dig(country, as_of.year) || 0.0
161
+ end
162
+ end
163
+ ```
164
+
165
+ When combined with [content addressing](CONTENT_ADDRESSING_V1.md), the `as_of` value
166
+ is part of the content key, so the cache is invalidated automatically when time changes.
167
+
168
+ ## Files
169
+
170
+ | File | Purpose |
171
+ |------|---------|
172
+ | `lib/igniter/temporal.rb` | `Temporal` module, `ClassMethods#define`, `temporal_compute` DSL helper, `Temporal::Executor` base class |
173
+ | `lib/igniter/runtime/input_validator.rb` | Proc defaults called at execution time (`apply_defaults`, `missing_value!`) |
174
+ | `spec/igniter/temporal_spec.rb` | 13 examples |