igniter 0.4.5 → 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/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/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -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/execution.rb +18 -0
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/resolver.rb +254 -16
- 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 +122 -1
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# examples/dataflow.rb
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates Igniter's incremental dataflow:
|
|
6
|
+
#
|
|
7
|
+
# Part 1 — Incremental Collection (mode: :incremental)
|
|
8
|
+
# Only added/changed items have their child contract re-run.
|
|
9
|
+
# Removed items are retracted automatically.
|
|
10
|
+
# window: { last: N } keeps a bounded sliding window in memory.
|
|
11
|
+
#
|
|
12
|
+
# Part 2 — Maintained Aggregates
|
|
13
|
+
# Aggregate nodes (count, sum, avg, min, max, group_count, custom) update
|
|
14
|
+
# in O(change) time — only the diff contributes, not the full collection.
|
|
15
|
+
#
|
|
16
|
+
# Typical use-cases:
|
|
17
|
+
# • IoT sensor streams — thousands of sensors, updates arrive as diffs
|
|
18
|
+
# • Live analytics — sliding-window aggregates with O(change) compute
|
|
19
|
+
# • Event-driven systems — process only the delta, not the full dataset
|
|
20
|
+
#
|
|
21
|
+
# Run with: bundle exec ruby examples/dataflow.rb
|
|
22
|
+
|
|
23
|
+
require_relative "../lib/igniter"
|
|
24
|
+
require_relative "../lib/igniter/extensions/dataflow"
|
|
25
|
+
|
|
26
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
27
|
+
# PART 1 — Incremental Collection + Sliding Window
|
|
28
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
29
|
+
|
|
30
|
+
# ─── Child contract: process a single sensor reading ──────────────────────────
|
|
31
|
+
class SensorAnalysis < Igniter::Contract
|
|
32
|
+
define do
|
|
33
|
+
input :sensor_id
|
|
34
|
+
input :value, type: :numeric
|
|
35
|
+
input :unit
|
|
36
|
+
|
|
37
|
+
compute :status, depends_on: :value do |value:|
|
|
38
|
+
case value
|
|
39
|
+
when (..0) then :error
|
|
40
|
+
when (0..25) then :normal
|
|
41
|
+
when (26..75) then :warning
|
|
42
|
+
else :critical
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
compute :label, depends_on: %i[sensor_id status] do |sensor_id:, status:|
|
|
47
|
+
"[#{sensor_id}] #{status.upcase}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
output :status
|
|
51
|
+
output :label
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# ─── Pipeline with sliding window ─────────────────────────────────────────────
|
|
56
|
+
class SensorPipeline < Igniter::Contract
|
|
57
|
+
define do
|
|
58
|
+
input :readings, type: :array
|
|
59
|
+
|
|
60
|
+
collection :processed,
|
|
61
|
+
with: :readings,
|
|
62
|
+
each: SensorAnalysis,
|
|
63
|
+
key: :sensor_id,
|
|
64
|
+
mode: :incremental,
|
|
65
|
+
window: { last: 5 }
|
|
66
|
+
|
|
67
|
+
output :processed
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
def print_diff(label, diff)
|
|
74
|
+
puts "\n#{label}"
|
|
75
|
+
puts " added: #{diff.added.inspect}"
|
|
76
|
+
puts " changed: #{diff.changed.inspect}"
|
|
77
|
+
puts " removed: #{diff.removed.inspect}"
|
|
78
|
+
puts " unchanged: #{diff.unchanged.inspect}"
|
|
79
|
+
puts " processed: #{diff.processed_count} child contract(s) re-run"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def print_results(processed)
|
|
83
|
+
processed.successes.each_value do |item|
|
|
84
|
+
puts " #{item.result.label}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
puts "═" * 60
|
|
89
|
+
puts "PART 1 — Incremental Collection + Sliding Window"
|
|
90
|
+
puts "═" * 60
|
|
91
|
+
|
|
92
|
+
# ── Round 1: initial batch — 4 sensors, all treated as :added ─────────────────
|
|
93
|
+
|
|
94
|
+
initial_readings = [
|
|
95
|
+
{ sensor_id: "tmp-1", value: 20, unit: "°C" },
|
|
96
|
+
{ sensor_id: "tmp-2", value: 45, unit: "°C" },
|
|
97
|
+
{ sensor_id: "hum-1", value: 80, unit: "%" },
|
|
98
|
+
{ sensor_id: "pres-1", value: 5, unit: "kPa" }
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
pipeline = SensorPipeline.new(readings: initial_readings)
|
|
102
|
+
pipeline.resolve_all
|
|
103
|
+
|
|
104
|
+
diff = pipeline.collection_diff(:processed)
|
|
105
|
+
print_diff("── Round 1: initial batch ──────────────────────────", diff)
|
|
106
|
+
print_results(pipeline.result.processed)
|
|
107
|
+
|
|
108
|
+
# ── Round 2: one sensor crosses critical threshold ────────────────────────────
|
|
109
|
+
|
|
110
|
+
pipeline.feed_diff(:readings, update: [{ sensor_id: "tmp-2", value: 90, unit: "°C" }])
|
|
111
|
+
pipeline.resolve_all
|
|
112
|
+
|
|
113
|
+
diff = pipeline.collection_diff(:processed)
|
|
114
|
+
print_diff("── Round 2: tmp-2 value 45 → 90 ───────────────────", diff)
|
|
115
|
+
print_results(pipeline.result.processed)
|
|
116
|
+
|
|
117
|
+
# ── Round 3: new sensor arrives ───────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
pipeline.feed_diff(:readings, add: [{ sensor_id: "wind-1", value: 15, unit: "m/s" }])
|
|
120
|
+
pipeline.resolve_all
|
|
121
|
+
|
|
122
|
+
diff = pipeline.collection_diff(:processed)
|
|
123
|
+
print_diff("── Round 3: wind-1 joins the stream ────────────────", diff)
|
|
124
|
+
print_results(pipeline.result.processed)
|
|
125
|
+
|
|
126
|
+
# ── Round 4: sensor goes offline ──────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
pipeline.feed_diff(:readings, remove: ["hum-1"])
|
|
129
|
+
pipeline.resolve_all
|
|
130
|
+
|
|
131
|
+
diff = pipeline.collection_diff(:processed)
|
|
132
|
+
print_diff("── Round 4: hum-1 removed ──────────────────────────", diff)
|
|
133
|
+
print_results(pipeline.result.processed)
|
|
134
|
+
|
|
135
|
+
# ── Round 5: identical update — zero re-runs ──────────────────────────────────
|
|
136
|
+
|
|
137
|
+
pipeline.update_inputs(readings: pipeline.execution.inputs[:readings].dup)
|
|
138
|
+
pipeline.resolve_all
|
|
139
|
+
|
|
140
|
+
diff = pipeline.collection_diff(:processed)
|
|
141
|
+
print_diff("── Round 5: no data changed (zero re-runs) ─────────", diff)
|
|
142
|
+
|
|
143
|
+
# ── Round 6: sliding window — adding 6th sensor evicts oldest ─────────────────
|
|
144
|
+
|
|
145
|
+
pipeline.feed_diff(:readings, add: [{ sensor_id: "co2-1", value: 60, unit: "ppm" }])
|
|
146
|
+
pipeline.resolve_all
|
|
147
|
+
|
|
148
|
+
diff = pipeline.collection_diff(:processed)
|
|
149
|
+
print_diff("── Round 6: co2-1 added (window: last 5) ───────────", diff)
|
|
150
|
+
puts "\n Active sensors in window: #{pipeline.result.processed.keys.inspect}"
|
|
151
|
+
|
|
152
|
+
# ─── Summary ──────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
puts "\n── Summary ─────────────────────────────────────────────"
|
|
155
|
+
puts " Final window: #{pipeline.result.processed.keys.inspect}"
|
|
156
|
+
puts " Diff explain: #{pipeline.collection_diff(:processed).explain}"
|
|
157
|
+
|
|
158
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
159
|
+
# PART 2 — Maintained Aggregates
|
|
160
|
+
#
|
|
161
|
+
# Aggregates update in O(change) time — only the diff items are processed.
|
|
162
|
+
# The AggregateState stores per-key contributions so that removed/changed items
|
|
163
|
+
# can be retracted without rescanning the full collection.
|
|
164
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
165
|
+
|
|
166
|
+
puts "\n"
|
|
167
|
+
puts "═" * 60
|
|
168
|
+
puts "PART 2 — Maintained Aggregates"
|
|
169
|
+
puts "═" * 60
|
|
170
|
+
|
|
171
|
+
# ─── Child contract: classify sensor with zone info ───────────────────────────
|
|
172
|
+
|
|
173
|
+
class SensorMetrics < Igniter::Contract
|
|
174
|
+
define do
|
|
175
|
+
input :sensor_id
|
|
176
|
+
input :value, type: :numeric
|
|
177
|
+
input :zone
|
|
178
|
+
|
|
179
|
+
compute :status, depends_on: :value do |value:|
|
|
180
|
+
value > 75 ? :critical : :normal
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
output :status
|
|
184
|
+
output :value # exposed for sum/avg/min/max projections
|
|
185
|
+
output :zone # exposed for group_count
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ─── Analytics pipeline with all built-in aggregate operators ─────────────────
|
|
190
|
+
|
|
191
|
+
class AnalyticsPipeline < Igniter::Contract
|
|
192
|
+
define do # rubocop:disable Metrics/BlockLength
|
|
193
|
+
input :sensors, type: :array
|
|
194
|
+
|
|
195
|
+
collection :processed,
|
|
196
|
+
with: :sensors,
|
|
197
|
+
each: SensorMetrics,
|
|
198
|
+
key: :sensor_id,
|
|
199
|
+
mode: :incremental
|
|
200
|
+
|
|
201
|
+
# ── Built-in operators ──────────────────────────────────────────────────
|
|
202
|
+
aggregate :total, from: :processed # count all
|
|
203
|
+
aggregate :high_count,
|
|
204
|
+
from: :processed,
|
|
205
|
+
count: ->(item) { item.result.status == :critical }
|
|
206
|
+
aggregate :total_value,
|
|
207
|
+
from: :processed,
|
|
208
|
+
sum: ->(item) { item.result.value.to_f }
|
|
209
|
+
aggregate :avg_value,
|
|
210
|
+
from: :processed,
|
|
211
|
+
avg: ->(item) { item.result.value.to_f }
|
|
212
|
+
aggregate :peak,
|
|
213
|
+
from: :processed,
|
|
214
|
+
max: ->(item) { item.result.value.to_f }
|
|
215
|
+
aggregate :by_zone,
|
|
216
|
+
from: :processed,
|
|
217
|
+
group_count: ->(item) { item.result.zone }
|
|
218
|
+
|
|
219
|
+
# ── Custom retractable aggregate ────────────────────────────────────────
|
|
220
|
+
# Maintains a sorted list of unique critical sensor IDs
|
|
221
|
+
critical_add = lambda do |acc, item|
|
|
222
|
+
item.result.status == :critical ? (acc + [item.key]).sort.uniq : acc
|
|
223
|
+
end
|
|
224
|
+
aggregate :critical_ids,
|
|
225
|
+
from: :processed,
|
|
226
|
+
initial: [],
|
|
227
|
+
add: critical_add,
|
|
228
|
+
remove: ->(acc, item) { acc - [item.key] }
|
|
229
|
+
|
|
230
|
+
output :processed
|
|
231
|
+
output :total
|
|
232
|
+
output :high_count
|
|
233
|
+
output :total_value
|
|
234
|
+
output :avg_value
|
|
235
|
+
output :peak
|
|
236
|
+
output :by_zone
|
|
237
|
+
output :critical_ids
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# ─── Helper ───────────────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def print_aggregates(result) # rubocop:disable Metrics/AbcSize
|
|
244
|
+
r = result
|
|
245
|
+
puts " total = #{r.total}"
|
|
246
|
+
puts " high_count = #{r.high_count} (critical sensors)"
|
|
247
|
+
puts " total_value = #{r.total_value.round(1)}"
|
|
248
|
+
puts " avg_value = #{r.avg_value.round(2)}"
|
|
249
|
+
puts " peak = #{r.peak.inspect}"
|
|
250
|
+
puts " by_zone = #{r.by_zone.inspect}"
|
|
251
|
+
puts " critical_ids= #{r.critical_ids.inspect}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
sensor = ->(id, value, zone) { { sensor_id: id, value: value, zone: zone } }
|
|
255
|
+
|
|
256
|
+
# ── Round A: initial batch ─────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
sensors_a = [
|
|
259
|
+
sensor.call("s1", 20, "north"), # normal
|
|
260
|
+
sensor.call("s2", 80, "north"), # critical
|
|
261
|
+
sensor.call("s3", 90, "south"), # critical
|
|
262
|
+
sensor.call("s4", 30, "south") # normal
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
ap = AnalyticsPipeline.new(sensors: sensors_a)
|
|
266
|
+
ap.resolve_all
|
|
267
|
+
|
|
268
|
+
puts "\n── Round A: initial batch (4 sensors) ──────────────────"
|
|
269
|
+
puts " diff: #{ap.collection_diff(:processed).processed_count} child contract(s) run"
|
|
270
|
+
print_aggregates(ap.result)
|
|
271
|
+
|
|
272
|
+
# ── Round B: s2 value drops (critical → normal) ───────────────────────────────
|
|
273
|
+
|
|
274
|
+
ap.feed_diff(:sensors, update: [sensor.call("s2", 40, "north")])
|
|
275
|
+
ap.resolve_all
|
|
276
|
+
|
|
277
|
+
puts "\n── Round B: s2 value 80 → 40 (critical → normal) ──────"
|
|
278
|
+
puts " diff: #{ap.collection_diff(:processed).processed_count} child contract(s) run"
|
|
279
|
+
print_aggregates(ap.result)
|
|
280
|
+
|
|
281
|
+
# ── Round C: new sensor added in a new zone ────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
ap.feed_diff(:sensors, add: [sensor.call("s5", 95, "east")])
|
|
284
|
+
ap.resolve_all
|
|
285
|
+
|
|
286
|
+
puts "\n── Round C: s5 added in zone 'east' (critical) ─────────"
|
|
287
|
+
puts " diff: #{ap.collection_diff(:processed).processed_count} child contract(s) run"
|
|
288
|
+
print_aggregates(ap.result)
|
|
289
|
+
|
|
290
|
+
# ── Round D: peak sensor removed ──────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
ap.feed_diff(:sensors, remove: ["s5"])
|
|
293
|
+
ap.resolve_all
|
|
294
|
+
|
|
295
|
+
puts "\n── Round D: s5 removed (peak was 95) ───────────────────"
|
|
296
|
+
puts " diff: #{ap.collection_diff(:processed).processed_count} child contract(s) run"
|
|
297
|
+
print_aggregates(ap.result)
|
|
298
|
+
|
|
299
|
+
# ── Round E: no change — zero re-runs, aggregates stable ──────────────────────
|
|
300
|
+
|
|
301
|
+
ap.update_inputs(sensors: ap.execution.inputs[:sensors].dup)
|
|
302
|
+
ap.resolve_all
|
|
303
|
+
|
|
304
|
+
puts "\n── Round E: no change (zero re-runs, aggregates stable) "
|
|
305
|
+
puts " diff: #{ap.collection_diff(:processed).processed_count} child contract(s) run"
|
|
306
|
+
print_aggregates(ap.result)
|
|
307
|
+
|
|
308
|
+
puts "\nDone."
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# examples/llm_tools.rb
|
|
4
|
+
#
|
|
5
|
+
# Demonstrates Igniter::Tool — AI-callable tools with:
|
|
6
|
+
# - Declarative metadata (description, param schema, required capabilities)
|
|
7
|
+
# - JSON schema generation for Anthropic / OpenAI APIs
|
|
8
|
+
# - Capability-based access guards (enforced before tool.call)
|
|
9
|
+
# - Automatic tool-use loop inside LLM::Executor#complete
|
|
10
|
+
# - ToolRegistry for global discovery and schema export
|
|
11
|
+
# - Tool reuse as regular Igniter::Contract compute nodes
|
|
12
|
+
#
|
|
13
|
+
# Requires: ANTHROPIC_API_KEY (skips live calls if absent)
|
|
14
|
+
#
|
|
15
|
+
# Run: bundle exec ruby examples/llm_tools.rb
|
|
16
|
+
|
|
17
|
+
require "igniter"
|
|
18
|
+
require "igniter/tool"
|
|
19
|
+
require "igniter/tool_registry"
|
|
20
|
+
require "igniter/integrations/llm"
|
|
21
|
+
|
|
22
|
+
puts "=" * 62
|
|
23
|
+
puts " Igniter::Tool Demo"
|
|
24
|
+
puts "=" * 62
|
|
25
|
+
|
|
26
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
# [1] Define tools
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
puts "\n[1] Defining tools"
|
|
30
|
+
|
|
31
|
+
class Calculator < Igniter::Tool
|
|
32
|
+
description "Evaluate a mathematical expression and return the result"
|
|
33
|
+
param :expression, type: :string, required: true, desc: "A Ruby-evaluable math expression"
|
|
34
|
+
|
|
35
|
+
def call(expression:)
|
|
36
|
+
result = eval(expression) # rubocop:disable Security/Eval
|
|
37
|
+
{ expression: expression, result: result }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class TimeNow < Igniter::Tool
|
|
42
|
+
description "Return the current UTC time"
|
|
43
|
+
# no params, no required capabilities
|
|
44
|
+
|
|
45
|
+
def call
|
|
46
|
+
{ utc: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class DatabaseLookup < Igniter::Tool
|
|
51
|
+
description "Look up a record in the (simulated) product database by SKU"
|
|
52
|
+
param :sku, type: :string, required: true, desc: "Product SKU identifier"
|
|
53
|
+
|
|
54
|
+
requires_capability :database_read # agent must declare this capability
|
|
55
|
+
|
|
56
|
+
PRODUCTS = {
|
|
57
|
+
"SKU-001" => { name: "Widget Pro", price: 29.99, stock: 150 },
|
|
58
|
+
"SKU-002" => { name: "Gadget Plus", price: 49.99, stock: 42 },
|
|
59
|
+
"SKU-003" => { name: "Doohickey Max", price: 99.00, stock: 7 },
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
def call(sku:)
|
|
63
|
+
product = PRODUCTS[sku.upcase]
|
|
64
|
+
product ? product.merge(sku: sku.upcase) : { error: "SKU #{sku} not found" }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts " Defined: Calculator, TimeNow, DatabaseLookup"
|
|
69
|
+
|
|
70
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
# [2] Schema generation
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
puts "\n[2] Schema generation"
|
|
74
|
+
|
|
75
|
+
puts " Calculator.tool_name: #{Calculator.tool_name}"
|
|
76
|
+
puts " DatabaseLookup.tool_name: #{DatabaseLookup.tool_name}"
|
|
77
|
+
puts " DatabaseLookup.required_capabilities: #{DatabaseLookup.required_capabilities.inspect}"
|
|
78
|
+
|
|
79
|
+
schema = Calculator.to_schema
|
|
80
|
+
puts "\n Intermediate schema (normalized by provider):"
|
|
81
|
+
puts " name: #{schema[:name]}"
|
|
82
|
+
puts " parameters: #{schema[:parameters].inspect}"
|
|
83
|
+
|
|
84
|
+
puts "\n Anthropic schema:"
|
|
85
|
+
require "json"
|
|
86
|
+
puts JSON.pretty_generate(Calculator.to_schema(:anthropic)).split("\n").map { |l| " #{l}" }.join("\n")
|
|
87
|
+
|
|
88
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
# [3] ToolRegistry
|
|
90
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
puts "\n[3] ToolRegistry"
|
|
92
|
+
|
|
93
|
+
Igniter::ToolRegistry.register(Calculator, TimeNow, DatabaseLookup)
|
|
94
|
+
puts " Registered #{Igniter::ToolRegistry.size} tools: #{Igniter::ToolRegistry.all.map(&:tool_name).join(", ")}"
|
|
95
|
+
|
|
96
|
+
# Capability filtering
|
|
97
|
+
public_tools = Igniter::ToolRegistry.tools_for(capabilities: [])
|
|
98
|
+
puts " Tools with no caps required: #{public_tools.map(&:tool_name).join(", ")}"
|
|
99
|
+
|
|
100
|
+
db_tools = Igniter::ToolRegistry.tools_for(capabilities: %i[database_read])
|
|
101
|
+
puts " Tools available with :database_read: #{db_tools.map(&:tool_name).join(", ")}"
|
|
102
|
+
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
# [4] Capability guard
|
|
105
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
puts "\n[4] Capability guard"
|
|
107
|
+
|
|
108
|
+
puts " Calling Calculator (no caps required)..."
|
|
109
|
+
result = Calculator.new.call_with_capability_check!(allowed_capabilities: [], expression: "6 * 7")
|
|
110
|
+
puts " 6 * 7 = #{result[:result]}"
|
|
111
|
+
|
|
112
|
+
puts " Calling DatabaseLookup without :database_read capability..."
|
|
113
|
+
begin
|
|
114
|
+
DatabaseLookup.new.call_with_capability_check!(allowed_capabilities: [], sku: "SKU-001")
|
|
115
|
+
puts " BUG: should have raised"
|
|
116
|
+
rescue Igniter::Tool::CapabilityError => e
|
|
117
|
+
puts " CapabilityError: #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
puts " Calling DatabaseLookup WITH :database_read capability..."
|
|
121
|
+
product = DatabaseLookup.new.call_with_capability_check!(
|
|
122
|
+
allowed_capabilities: [:database_read], sku: "SKU-001"
|
|
123
|
+
)
|
|
124
|
+
puts " Found: #{product[:name]} — $#{product[:price]}"
|
|
125
|
+
|
|
126
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
# [5] Tool as a regular Igniter::Contract compute node
|
|
128
|
+
# Tool IS an Executor — full Contract compatibility
|
|
129
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
puts "\n[5] Tool as Contract compute node"
|
|
131
|
+
|
|
132
|
+
class PriceChecker < Igniter::Contract
|
|
133
|
+
define do
|
|
134
|
+
input :sku
|
|
135
|
+
|
|
136
|
+
compute :product, with: :sku, call: DatabaseLookup
|
|
137
|
+
compute :discount, with: :product, call: Class.new(Igniter::Executor) {
|
|
138
|
+
def call(product:)
|
|
139
|
+
return { rate: 0.2, reason: "low stock" } if product[:stock] < 10
|
|
140
|
+
{ rate: 0.05, reason: "standard" }
|
|
141
|
+
end
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
output :product
|
|
145
|
+
output :discount
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
checker = PriceChecker.new(sku: "SKU-003")
|
|
150
|
+
checker.resolve_all
|
|
151
|
+
puts " SKU-003: #{checker.result.product[:name]}"
|
|
152
|
+
puts " Stock: #{checker.result.product[:stock]} → discount: #{(checker.result.discount[:rate] * 100).to_i}% (#{checker.result.discount[:reason]})"
|
|
153
|
+
|
|
154
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
# [6] LLM::Executor with automatic tool-use loop
|
|
156
|
+
# (live call requires ANTHROPIC_API_KEY)
|
|
157
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
puts "\n[6] LLM::Executor with automatic tool-use loop"
|
|
159
|
+
|
|
160
|
+
if ENV["ANTHROPIC_API_KEY"].to_s.empty?
|
|
161
|
+
puts " ANTHROPIC_API_KEY not set — skipping live call"
|
|
162
|
+
puts " (showing executor definition only)"
|
|
163
|
+
|
|
164
|
+
class ProductAgent < Igniter::LLM::Executor
|
|
165
|
+
provider :anthropic
|
|
166
|
+
model "claude-haiku-4-5-20251001"
|
|
167
|
+
system_prompt "You are a helpful product assistant. Use tools to answer questions."
|
|
168
|
+
|
|
169
|
+
tools Calculator, TimeNow, DatabaseLookup
|
|
170
|
+
|
|
171
|
+
# This agent may use the database — declare the capability
|
|
172
|
+
capabilities :database_read
|
|
173
|
+
|
|
174
|
+
max_tool_iterations 5
|
|
175
|
+
|
|
176
|
+
def call(question:)
|
|
177
|
+
complete(question)
|
|
178
|
+
# complete() detects Tool classes in tools DSL and auto-loops:
|
|
179
|
+
# 1. Sends tool schemas to Anthropic API
|
|
180
|
+
# 2. LLM responds with tool_use blocks
|
|
181
|
+
# 3. Capability guard checks agent.declared_capabilities
|
|
182
|
+
# 4. Tool#call executes, result appended to conversation
|
|
183
|
+
# 5. Loop until LLM returns plain text
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
puts " ProductAgent defined with:"
|
|
188
|
+
puts " tools: #{ProductAgent.tools.map(&:tool_name).join(", ")}"
|
|
189
|
+
puts " capabilities: #{ProductAgent.declared_capabilities.inspect}"
|
|
190
|
+
puts " max_iters: #{ProductAgent.max_tool_iterations}"
|
|
191
|
+
else
|
|
192
|
+
class ProductAgent < Igniter::LLM::Executor
|
|
193
|
+
provider :anthropic
|
|
194
|
+
model "claude-haiku-4-5-20251001"
|
|
195
|
+
system_prompt "You are a helpful product assistant. Use tools to answer questions."
|
|
196
|
+
|
|
197
|
+
tools Calculator, TimeNow, DatabaseLookup
|
|
198
|
+
capabilities :database_read
|
|
199
|
+
max_tool_iterations 5
|
|
200
|
+
|
|
201
|
+
def call(question:)
|
|
202
|
+
complete(question)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
puts " Asking: 'What is the price of SKU-001 and what is 15% of that price?'"
|
|
207
|
+
begin
|
|
208
|
+
answer = ProductAgent.new.call(question: "What is the price of SKU-001 and what is 15% of that price?")
|
|
209
|
+
puts " Agent: #{answer}"
|
|
210
|
+
rescue Igniter::LLM::Error => e
|
|
211
|
+
puts " LLM error: #{e.message}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
# [7] Agent without database_read — CapabilityError before tool executes
|
|
217
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
puts "\n[7] Agent with restricted capabilities"
|
|
219
|
+
|
|
220
|
+
class RestrictedAgent < Igniter::LLM::Executor
|
|
221
|
+
provider :anthropic
|
|
222
|
+
model "claude-haiku-4-5-20251001"
|
|
223
|
+
system_prompt "You are a limited assistant."
|
|
224
|
+
|
|
225
|
+
tools Calculator, TimeNow, DatabaseLookup
|
|
226
|
+
# no capabilities declared — cannot call DatabaseLookup
|
|
227
|
+
max_tool_iterations 3
|
|
228
|
+
|
|
229
|
+
def call(question:)
|
|
230
|
+
complete(question)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
puts " RestrictedAgent.declared_capabilities: #{RestrictedAgent.declared_capabilities.inspect}"
|
|
235
|
+
puts " Tools it may safely call: #{Igniter::ToolRegistry.tools_for(capabilities: RestrictedAgent.declared_capabilities).map(&:tool_name).join(", ")}"
|
|
236
|
+
|
|
237
|
+
puts "\nDone."
|