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.
Files changed (154) 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/llm_tools.rb +237 -0
  22. data/examples/mesh.rb +239 -0
  23. data/examples/mesh_discovery.rb +267 -0
  24. data/examples/mesh_gossip.rb +162 -0
  25. data/examples/ringcentral_routing.rb +1 -1
  26. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  27. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  28. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  29. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  30. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  31. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  32. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  33. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  34. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  35. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  36. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  37. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  38. data/lib/igniter/agents/proactive_agent.rb +208 -0
  39. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  40. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  41. data/lib/igniter/agents.rb +56 -0
  42. data/lib/igniter/application/app_config.rb +32 -0
  43. data/lib/igniter/application/autoloader.rb +18 -0
  44. data/lib/igniter/application/generator.rb +157 -0
  45. data/lib/igniter/application/scheduler.rb +109 -0
  46. data/lib/igniter/application/yml_loader.rb +39 -0
  47. data/lib/igniter/application.rb +174 -0
  48. data/lib/igniter/capabilities.rb +68 -0
  49. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  50. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  51. data/lib/igniter/consensus/cluster.rb +183 -0
  52. data/lib/igniter/consensus/errors.rb +14 -0
  53. data/lib/igniter/consensus/executors.rb +43 -0
  54. data/lib/igniter/consensus/node.rb +320 -0
  55. data/lib/igniter/consensus/read_query.rb +30 -0
  56. data/lib/igniter/consensus/state_machine.rb +58 -0
  57. data/lib/igniter/consensus.rb +58 -0
  58. data/lib/igniter/content_addressing.rb +133 -0
  59. data/lib/igniter/contract.rb +12 -0
  60. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  61. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  62. data/lib/igniter/dataflow/diff.rb +37 -0
  63. data/lib/igniter/dataflow/diff_state.rb +81 -0
  64. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  65. data/lib/igniter/dataflow/window_filter.rb +48 -0
  66. data/lib/igniter/dataflow.rb +65 -0
  67. data/lib/igniter/dsl/contract_builder.rb +71 -7
  68. data/lib/igniter/executor.rb +60 -0
  69. data/lib/igniter/extensions/capabilities.rb +39 -0
  70. data/lib/igniter/extensions/content_addressing.rb +5 -0
  71. data/lib/igniter/extensions/dataflow.rb +117 -0
  72. data/lib/igniter/extensions/mesh.rb +31 -0
  73. data/lib/igniter/fingerprint.rb +43 -0
  74. data/lib/igniter/integrations/llm/config.rb +48 -4
  75. data/lib/igniter/integrations/llm/executor.rb +221 -28
  76. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  77. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  78. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  79. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  80. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  81. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  82. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  83. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  84. data/lib/igniter/integrations/llm.rb +37 -1
  85. data/lib/igniter/memory/agent_memory.rb +104 -0
  86. data/lib/igniter/memory/episode.rb +29 -0
  87. data/lib/igniter/memory/fact.rb +27 -0
  88. data/lib/igniter/memory/memorable.rb +90 -0
  89. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  90. data/lib/igniter/memory/reflection_record.rb +28 -0
  91. data/lib/igniter/memory/store.rb +115 -0
  92. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  93. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  94. data/lib/igniter/memory.rb +80 -0
  95. data/lib/igniter/mesh/announcer.rb +55 -0
  96. data/lib/igniter/mesh/config.rb +45 -0
  97. data/lib/igniter/mesh/discovery.rb +39 -0
  98. data/lib/igniter/mesh/errors.rb +31 -0
  99. data/lib/igniter/mesh/gossip.rb +47 -0
  100. data/lib/igniter/mesh/peer.rb +21 -0
  101. data/lib/igniter/mesh/peer_registry.rb +51 -0
  102. data/lib/igniter/mesh/poller.rb +77 -0
  103. data/lib/igniter/mesh/router.rb +109 -0
  104. data/lib/igniter/mesh.rb +85 -0
  105. data/lib/igniter/metrics/collector.rb +131 -0
  106. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  107. data/lib/igniter/metrics/snapshot.rb +8 -0
  108. data/lib/igniter/metrics.rb +37 -0
  109. data/lib/igniter/model/aggregate_node.rb +34 -0
  110. data/lib/igniter/model/collection_node.rb +3 -2
  111. data/lib/igniter/model/compute_node.rb +13 -0
  112. data/lib/igniter/model/remote_node.rb +18 -2
  113. data/lib/igniter/node_cache.rb +231 -0
  114. data/lib/igniter/replication/bootstrapper.rb +61 -0
  115. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  116. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  117. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  118. data/lib/igniter/replication/expansion_plan.rb +38 -0
  119. data/lib/igniter/replication/expansion_planner.rb +142 -0
  120. data/lib/igniter/replication/manifest.rb +45 -0
  121. data/lib/igniter/replication/network_topology.rb +123 -0
  122. data/lib/igniter/replication/node_role.rb +42 -0
  123. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  124. data/lib/igniter/replication/replication_agent.rb +87 -0
  125. data/lib/igniter/replication/role_registry.rb +73 -0
  126. data/lib/igniter/replication/ssh_session.rb +77 -0
  127. data/lib/igniter/replication.rb +54 -0
  128. data/lib/igniter/runtime/execution.rb +18 -0
  129. data/lib/igniter/runtime/input_validator.rb +6 -2
  130. data/lib/igniter/runtime/resolver.rb +254 -16
  131. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  132. data/lib/igniter/server/client.rb +44 -1
  133. data/lib/igniter/server/config.rb +13 -6
  134. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  135. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  136. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  137. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  138. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  139. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  140. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  141. data/lib/igniter/server/http_server.rb +54 -17
  142. data/lib/igniter/server/router.rb +54 -21
  143. data/lib/igniter/server/server_logger.rb +52 -0
  144. data/lib/igniter/server.rb +6 -0
  145. data/lib/igniter/skill/feedback.rb +116 -0
  146. data/lib/igniter/skill/output_schema.rb +110 -0
  147. data/lib/igniter/skill.rb +218 -0
  148. data/lib/igniter/temporal.rb +84 -0
  149. data/lib/igniter/tool/discoverable.rb +151 -0
  150. data/lib/igniter/tool.rb +52 -0
  151. data/lib/igniter/tool_registry.rb +144 -0
  152. data/lib/igniter/version.rb +1 -1
  153. data/lib/igniter.rb +17 -0
  154. 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."