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
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
|
-
|
|
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 |
|
data/docs/STORE_ADAPTERS.md
CHANGED
|
@@ -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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
data/docs/TEMPORAL_V1.md
ADDED
|
@@ -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 |
|