igniter 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Skill
5
+ # Immutable record of one rated invocation of a Skill.
6
+ FeedbackEntry = Struct.new(
7
+ :input, # String — the prompt that was sent to the LLM
8
+ :output, # String — the response that was returned
9
+ :rating, # Symbol — :good, :bad, or :neutral
10
+ :notes, # String, nil — optional human comment
11
+ :timestamp, # Time
12
+ keyword_init: true
13
+ ) do
14
+ def initialize(**)
15
+ super
16
+ freeze
17
+ end
18
+ end
19
+
20
+ # Thread-safe in-memory store for feedback entries.
21
+ module FeedbackStore
22
+ class Memory
23
+ MAX_SIZE = 500
24
+
25
+ def initialize
26
+ @entries = []
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ def store(entry)
31
+ @mutex.synchronize do
32
+ @entries << entry
33
+ @entries.shift if @entries.size > MAX_SIZE
34
+ end
35
+ self
36
+ end
37
+
38
+ def all
39
+ @mutex.synchronize { @entries.dup }
40
+ end
41
+
42
+ def size
43
+ @mutex.synchronize { @entries.size }
44
+ end
45
+
46
+ def empty?
47
+ size.zero?
48
+ end
49
+
50
+ def by_rating(rating)
51
+ all.select { |e| e.rating == rating.to_sym }
52
+ end
53
+
54
+ def clear
55
+ @mutex.synchronize { @entries.clear }
56
+ self
57
+ end
58
+ end
59
+ end
60
+
61
+ # Generates an improved system prompt from accumulated feedback.
62
+ #
63
+ # Uses the skill's own LLM provider to propose changes. Returns a plain
64
+ # String — it does NOT mutate any class-level state. The caller decides
65
+ # whether to adopt the refined prompt.
66
+ #
67
+ # == Usage
68
+ #
69
+ # improved = skill.refine_system_prompt
70
+ # # Inspect it, then use it:
71
+ # MySkill.system_prompt improved
72
+ class FeedbackRefiner
73
+ TEMPLATE = <<~PROMPT
74
+ You are improving a system prompt for an AI skill based on user feedback.
75
+ Return ONLY the improved system prompt text, with no explanation or preamble.
76
+
77
+ Current system prompt:
78
+ %<current>s
79
+
80
+ %<feedback>s
81
+ PROMPT
82
+
83
+ def initialize(provider_instance, model)
84
+ @provider = provider_instance
85
+ @model = model
86
+ end
87
+
88
+ # @param current_prompt [String]
89
+ # @param entries [Array<FeedbackEntry>]
90
+ # @return [String] the refined system prompt
91
+ def refine(current_prompt, entries) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
92
+ return current_prompt if entries.empty?
93
+
94
+ good = entries.select { |e| e.rating == :good }
95
+ .filter_map { |e| e.notes && "✓ #{e.notes}" }
96
+ .join("\n")
97
+
98
+ bad = entries.select { |e| e.rating == :bad }
99
+ .filter_map { |e| e.notes && "✗ #{e.notes}" }
100
+ .join("\n")
101
+
102
+ parts = []
103
+ parts << "Positive feedback (preserve these qualities):\n#{good}" unless good.empty?
104
+ parts << "Negative feedback (address these issues):\n#{bad}" unless bad.empty?
105
+
106
+ return current_prompt if parts.empty?
107
+
108
+ prompt = format(TEMPLATE, current: current_prompt, feedback: parts.join("\n\n"))
109
+ @provider.chat(
110
+ messages: [{ role: "user", content: prompt }],
111
+ model: @model
112
+ )[:content]
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Igniter
6
+ class Skill
7
+ # DSL for declaring a typed JSON output contract on a Skill.
8
+ #
9
+ # When an +output_schema+ block is given, the Skill automatically:
10
+ # 1. Appends a JSON instruction to every prompt sent to the LLM.
11
+ # 2. Parses the LLM response as JSON on return.
12
+ # 3. Wraps the parsed data in a +StructuredResult+ with field readers.
13
+ #
14
+ # == Definition
15
+ #
16
+ # class AnalysisSkill < Igniter::Skill
17
+ # output_schema do
18
+ # field :summary, String
19
+ # field :confidence, Float
20
+ # field :sources, Array
21
+ # end
22
+ # end
23
+ #
24
+ # == Usage
25
+ #
26
+ # result = AnalysisSkill.call(document: "...")
27
+ # result.summary # => "This document covers..."
28
+ # result.confidence # => 0.91
29
+ # result.to_h # => { summary: "...", confidence: 0.91, sources: [...] }
30
+ class OutputSchema
31
+ # Raised when the LLM response cannot be parsed into the declared schema.
32
+ class ParseError < Igniter::Error; end
33
+
34
+ # Maps Ruby constant types to JSON Schema type strings.
35
+ TYPE_MAP = {
36
+ String => "string",
37
+ Integer => "number",
38
+ Float => "number",
39
+ Array => "array",
40
+ Hash => "object"
41
+ }.freeze
42
+
43
+ Field = Struct.new(:name, :type, keyword_init: true)
44
+
45
+ def initialize(&block)
46
+ @fields = []
47
+ instance_eval(&block) if block
48
+ end
49
+
50
+ # Declare a field in the output schema.
51
+ # @param name [Symbol, String] field name
52
+ # @param type [Class] expected Ruby type (String, Integer, Float, Array, Hash)
53
+ def field(name, type)
54
+ @fields << Field.new(name: name.to_sym, type: type)
55
+ self
56
+ end
57
+
58
+ # Frozen array of declared fields.
59
+ def fields
60
+ @fields.dup
61
+ end
62
+
63
+ # Human-readable JSON description injected into the prompt.
64
+ # Example: { "summary": string, "confidence": number }
65
+ def to_json_description
66
+ pairs = @fields.map { |f| "\"#{f.name}\": #{TYPE_MAP.fetch(f.type, "string")}" }
67
+ "{ #{pairs.join(", ")} }"
68
+ end
69
+
70
+ # Parse LLM response text into a +StructuredResult+.
71
+ # Extracts the first JSON object found in +text+.
72
+ #
73
+ # @param text [String] raw LLM response
74
+ # @return [StructuredResult]
75
+ # @raise [ParseError] if no JSON object is found or JSON is invalid
76
+ def parse(text)
77
+ json_str = text.match(/\{.*\}/m)&.to_s
78
+ raise ParseError, "No JSON object found in LLM response" unless json_str
79
+
80
+ data = JSON.parse(json_str)
81
+ StructuredResult.new(@fields, data)
82
+ rescue JSON::ParserError => e
83
+ raise ParseError, "Invalid JSON in LLM response: #{e.message}"
84
+ end
85
+ end
86
+
87
+ # A typed result object returned by skills with an +output_schema+.
88
+ # Provides reader methods for each declared field plus +to_h+ / +to_json+.
89
+ class StructuredResult
90
+ def initialize(fields, data)
91
+ @fields = fields
92
+ @data = data.transform_keys(&:to_sym)
93
+ fields.each { |f| define_singleton_method(f.name) { @data[f.name] } }
94
+ end
95
+
96
+ # Returns a plain Hash keyed by field names (symbols).
97
+ def to_h
98
+ @fields.each_with_object({}) { |f, h| h[f.name] = @data[f.name] }
99
+ end
100
+
101
+ def to_json(*args)
102
+ to_h.to_json(*args)
103
+ end
104
+
105
+ def inspect
106
+ "#<Igniter::Skill::StructuredResult #{to_h}>"
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tool"
4
+ require_relative "integrations/llm/executor"
5
+
6
+ module Igniter
7
+ # Base class for AI-callable skills — composable units of agent capability.
8
+ #
9
+ # A +Skill+ is both discoverable (LLM can call it like a Tool) and agentic
10
+ # (it runs its own LLM reasoning loop with its own set of tools).
11
+ # Use Skills when a single tool call is not enough: the task requires planning,
12
+ # multi-step tool use, or internal LLM reasoning.
13
+ #
14
+ # == Tool vs Skill
15
+ #
16
+ # +Tool+ — atomic operation, single call, stateless, fast.
17
+ # +Skill+ — multi-step process, own LLM loop, own tools, may take seconds.
18
+ #
19
+ # From the parent agent's perspective both look identical: they share the same
20
+ # discovery interface (+description+, +param+, +to_schema+, +requires_capability+)
21
+ # and are registered in +ToolRegistry+ the same way.
22
+ #
23
+ # == Defining a skill
24
+ #
25
+ # class ResearchSkill < Igniter::Skill
26
+ # description "Research a topic by searching and synthesizing multiple sources"
27
+ #
28
+ # param :topic, type: :string, required: true,
29
+ # desc: "Subject to research"
30
+ #
31
+ # requires_capability :network
32
+ #
33
+ # provider :anthropic
34
+ # model "claude-sonnet-4-6"
35
+ # tools SearchWebTool, ReadUrlTool # skill's own sub-tools
36
+ # max_tool_iterations 8
37
+ #
38
+ # def call(topic:)
39
+ # complete("Research this thoroughly: #{topic}")
40
+ # # ↑ runs sub-tool loop internally; returns plain-text summary
41
+ # end
42
+ # end
43
+ #
44
+ # == Structured output
45
+ #
46
+ # class AnalysisSkill < Igniter::Skill
47
+ # output_schema do
48
+ # field :summary, String
49
+ # field :confidence, Float
50
+ # end
51
+ #
52
+ # def call(document:)
53
+ # complete("Analyse: #{document}")
54
+ # # Returns StructuredResult, not a plain String
55
+ # end
56
+ # end
57
+ #
58
+ # == Feedback loop
59
+ #
60
+ # class MySkill < Igniter::Skill
61
+ # feedback_enabled true
62
+ # feedback_store :memory
63
+ #
64
+ # def call(prompt:) = complete(prompt)
65
+ # end
66
+ #
67
+ # result = MySkill.call(prompt: "...")
68
+ # MySkill.new.feedback(result, rating: :good, notes: "Very helpful")
69
+ # improved = MySkill.new.refine_system_prompt
70
+ #
71
+ # == Hierarchical agents
72
+ #
73
+ # class ChatExecutor < Igniter::LLM::Executor
74
+ # tools TimeTool, WeatherTool,
75
+ # ResearchSkill, # ← parent sees this as a Tool
76
+ # WriteCodeSkill # ← parent sees this as a Tool
77
+ # end
78
+ #
79
+ # == Schema + registry
80
+ #
81
+ # ResearchSkill.tool_name # => "research_skill"
82
+ # ResearchSkill.to_schema # => { name:, description:, parameters: { ... } }
83
+ # Igniter::ToolRegistry.register(ResearchSkill)
84
+ class Skill < LLM::Executor
85
+ # CapabilityError is the same class as Tool::CapabilityError.
86
+ # Defined here as an alias for convenience and symmetry.
87
+ CapabilityError = Tool::CapabilityError
88
+
89
+ include Tool::Discoverable
90
+
91
+ class << self
92
+ # Propagate BOTH the LLM executor config (via super → LLM::Executor.inherited)
93
+ # AND the Discoverable metadata to every subclass.
94
+ # Note: @feedback_store is intentionally NOT copied — each class owns its store.
95
+ def inherited(subclass)
96
+ super
97
+ subclass.instance_variable_set(:@output_schema, @output_schema)
98
+ subclass.instance_variable_set(:@feedback_enabled, @feedback_enabled)
99
+ copy_discoverable_state_to(subclass)
100
+ end
101
+
102
+ # Declare a typed JSON output schema for this skill.
103
+ #
104
+ # When a block is given, creates an +OutputSchema+ and stores it.
105
+ # Calling +complete+ inside +call+ will then inject a JSON instruction
106
+ # into the prompt and return a +StructuredResult+ instead of a String.
107
+ #
108
+ # Without a block, falls back to the inherited +Executor#output_schema+
109
+ # metadata getter/setter for backward compatibility.
110
+ #
111
+ # @example
112
+ # output_schema do
113
+ # field :summary, String
114
+ # field :confidence, Float
115
+ # field :sources, Array
116
+ # end
117
+ def output_schema(value = nil, &block)
118
+ if block
119
+ @output_schema = Skill::OutputSchema.new(&block)
120
+ elsif value
121
+ super(value) # Executor metadata setter (backward compat)
122
+ else
123
+ @output_schema || super # new DSL ivar or executor_metadata fallback
124
+ end
125
+ end
126
+
127
+ # Enable or query feedback collection for this skill.
128
+ # When enabled, +#feedback+ stores entries in the configured store.
129
+ #
130
+ # @param val [Boolean, nil] pass true/false to set; nil to get
131
+ def feedback_enabled(val = nil)
132
+ val.nil? ? (@feedback_enabled || false) : (@feedback_enabled = val)
133
+ end
134
+
135
+ # Set or get the feedback store for this skill.
136
+ #
137
+ # Pass +:memory+ to create a new in-memory store (one per class).
138
+ # Pass any object responding to +#store+, +#all+, and +#clear+ to use a custom store.
139
+ #
140
+ # Note: the store is NOT inherited by subclasses — each class has its own.
141
+ #
142
+ # @param val [:memory, #store, nil]
143
+ def feedback_store(val = nil)
144
+ return @feedback_store if val.nil?
145
+
146
+ @feedback_store = val == :memory ? Skill::FeedbackStore::Memory.new : val
147
+ end
148
+ end
149
+
150
+ protected
151
+
152
+ # Override LLM::Executor#complete to inject a JSON instruction when an
153
+ # +OutputSchema+ is declared, and parse the response into a +StructuredResult+.
154
+ def complete(prompt, context: nil)
155
+ schema = self.class.output_schema
156
+
157
+ adjusted = if schema.is_a?(Skill::OutputSchema)
158
+ "#{prompt}\n\nRespond ONLY with valid JSON matching this schema: #{schema.to_json_description}"
159
+ else
160
+ prompt
161
+ end
162
+
163
+ result = super(adjusted, context: context)
164
+ schema.is_a?(Skill::OutputSchema) ? schema.parse(result) : result
165
+ end
166
+
167
+ public
168
+
169
+ # Record feedback for a previous output.
170
+ #
171
+ # Matches +output+ against +call_history+ to capture the input context.
172
+ # No-op when +feedback_enabled+ is false or no store is configured.
173
+ #
174
+ # @param output [String, StructuredResult] the response to rate
175
+ # @param rating [:good, :bad, :neutral]
176
+ # @param notes [String, nil]
177
+ # @return [self]
178
+ def feedback(output, rating:, notes: nil) # rubocop:disable Metrics/MethodLength
179
+ return self unless self.class.feedback_enabled
180
+
181
+ store = self.class.feedback_store
182
+ return self unless store
183
+
184
+ output_str = output.to_s
185
+ matched = (call_history || []).reverse.find { |h| h[:output] == output_str }
186
+
187
+ store.store(FeedbackEntry.new(
188
+ input: matched&.dig(:input),
189
+ output: output_str,
190
+ rating: rating.to_sym,
191
+ notes: notes,
192
+ timestamp: Time.now
193
+ ))
194
+ self
195
+ end
196
+
197
+ # Generate an improved system prompt based on accumulated feedback.
198
+ #
199
+ # Uses the skill's own LLM provider + model. Returns a new String — does NOT
200
+ # mutate class-level state. The caller decides whether to adopt the result.
201
+ #
202
+ # @return [String] the refined system prompt
203
+ # @raise [Igniter::Error] if no feedback_store is configured
204
+ def refine_system_prompt
205
+ store = self.class.feedback_store
206
+ raise Igniter::Error, "No feedback_store configured on #{self.class.name}" unless store
207
+
208
+ FeedbackRefiner.new(provider_instance, current_model).refine(
209
+ self.class.system_prompt.to_s,
210
+ store.all
211
+ )
212
+ end
213
+ end
214
+ end
215
+
216
+ # Load sub-files that reopen Skill after it is fully defined above.
217
+ require_relative "skill/output_schema"
218
+ require_relative "skill/feedback"
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Mixin for time-aware contracts.
5
+ #
6
+ # Including Igniter::Temporal in a Contract automatically injects an `as_of`
7
+ # input (default: Time.now) and provides the `temporal_compute` DSL helper,
8
+ # which adds `:as_of` to a node's dependencies automatically.
9
+ #
10
+ # The key property of a temporal contract: if ALL time-varying nodes depend on
11
+ # `as_of`, any historical execution is fully reproducible — just supply the
12
+ # original timestamp as the `as_of` input.
13
+ #
14
+ # == Usage
15
+ #
16
+ # require "igniter/temporal"
17
+ #
18
+ # class TaxRateContract < Igniter::Contract
19
+ # include Igniter::Temporal
20
+ #
21
+ # define do
22
+ # input :country
23
+ # # `as_of` is injected automatically (default: Time.now)
24
+ #
25
+ # temporal_compute :tax_rate, depends_on: :country do |country:, as_of:|
26
+ # HistoricalTaxRates.lookup(country: country, date: as_of.to_date)
27
+ # end
28
+ #
29
+ # output :tax_rate
30
+ # end
31
+ # end
32
+ #
33
+ # # Current rates:
34
+ # TaxRateContract.new(country: "UA").result.tax_rate
35
+ #
36
+ # # Reproduce a historical result:
37
+ # TaxRateContract.new(country: "UA", as_of: Time.new(2024, 1, 1)).result.tax_rate
38
+ #
39
+ # == TemporalExecutor
40
+ #
41
+ # For class-based executors in temporal contracts, inherit from
42
+ # Igniter::Temporal::Executor. It ensures `as_of:` is always passed as
43
+ # a keyword argument by the resolver.
44
+ #
45
+ # class TaxRateExecutor < Igniter::Temporal::Executor
46
+ # def call(country:, as_of:)
47
+ # HistoricalTaxRates.lookup(country: country, date: as_of.to_date)
48
+ # end
49
+ # end
50
+ module Temporal
51
+ def self.included(base)
52
+ base.extend(ClassMethods)
53
+ end
54
+
55
+ module ClassMethods
56
+ # Returns true for contracts that include Temporal.
57
+ def temporal? = true
58
+
59
+ # Override define to inject `as_of` and the `temporal_compute` builder helper
60
+ # before the user's block runs.
61
+ def define(&user_block)
62
+ super do
63
+ # Inject temporal input first so it can be used as a dependency.
64
+ input :as_of, default: -> { Time.now }
65
+
66
+ # Add `temporal_compute` as a convenience method on this builder instance.
67
+ # It behaves like `compute` but automatically adds `:as_of` to depends_on.
68
+ define_singleton_method(:temporal_compute) do |name, depends_on: [], **opts, &blk|
69
+ deps = (Array(depends_on) | [:as_of])
70
+ compute(name, depends_on: deps, **opts, &blk)
71
+ end
72
+
73
+ instance_eval(&user_block)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Base executor for temporal compute nodes.
79
+ # Inheriting from this signals that the executor expects `as_of:` among its kwargs.
80
+ class Executor < Igniter::Executor
81
+ # Subclasses must implement: def call(**deps_including_as_of)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Tool
5
+ # Shared discovery DSL included by both +Igniter::Tool+ and +Igniter::Skill+.
6
+ #
7
+ # Provides: +description+, +param+, +requires_capability+, +tool_name+,
8
+ # +to_schema+, and the instance-side +call_with_capability_check!+.
9
+ #
10
+ # Classes that include this module must call +copy_discoverable_state_to(subclass)+
11
+ # inside their own +inherited+ hook so the metadata propagates to subclasses.
12
+ module Discoverable
13
+ # Ruby type → JSON Schema type string
14
+ JSON_TYPES = {
15
+ string: "string", str: "string",
16
+ integer: "integer", int: "integer",
17
+ float: "number", number: "number",
18
+ boolean: "boolean", bool: "boolean",
19
+ array: "array",
20
+ object: "object",
21
+ }.freeze
22
+
23
+ def self.included(base)
24
+ base.extend(ClassMethods)
25
+ base.instance_variable_set(:@tool_params, [])
26
+ base.instance_variable_set(:@required_capabilities, [].freeze)
27
+ end
28
+
29
+ # ── Class-level DSL ────────────────────────────────────────────────────
30
+ module ClassMethods
31
+ # Describe what the tool/skill does. Sent to the LLM as part of its schema.
32
+ def description(text = nil)
33
+ text ? (@tool_description = text.freeze) : @tool_description
34
+ end
35
+
36
+ # Declare an LLM-visible input parameter.
37
+ #
38
+ # @param name [Symbol] parameter name (keyword arg in #call)
39
+ # @param type [Symbol] :string, :integer, :float, :boolean, :array, :object
40
+ # @param required [Boolean] whether the LLM must supply this value
41
+ # @param default [Object] informational default (not enforced at call-time)
42
+ # @param desc [String] short description for the LLM
43
+ def param(name, type:, required: false, default: nil, desc: nil)
44
+ tool_params << {
45
+ name: name.to_sym,
46
+ type: type.to_sym,
47
+ required: required,
48
+ default: default,
49
+ desc: desc.to_s,
50
+ }.freeze
51
+ end
52
+
53
+ # Declare capabilities the calling agent must have before this tool/skill
54
+ # is allowed to run. +CapabilityError+ is raised before +#call+ if any
55
+ # required capability is missing from the agent's +declared_capabilities+.
56
+ def requires_capability(*caps)
57
+ @required_capabilities = caps.flatten.map(&:to_sym).freeze
58
+ end
59
+
60
+ # ── Read-only accessors ──────────────────────────────────────────────
61
+
62
+ def tool_params
63
+ @tool_params ||= []
64
+ end
65
+
66
+ def required_capabilities
67
+ @required_capabilities || [].freeze
68
+ end
69
+
70
+ # Snake-case name derived from the class name (last namespace component).
71
+ #
72
+ # class SearchWebTool < Igniter::Tool → "search_web_tool"
73
+ # class ResearchSkill < Igniter::Skill → "research_skill"
74
+ def tool_name
75
+ n = name.to_s.split("::").last
76
+ return "anonymous" if n.nil? || n.empty?
77
+
78
+ n.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
79
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
80
+ .downcase
81
+ end
82
+
83
+ # Generate a tool schema for the given provider.
84
+ # With no argument returns the provider-agnostic intermediate format
85
+ # (used internally and processed by each provider's +normalize_tools+).
86
+ #
87
+ # @param provider [Symbol, nil] :anthropic, :openai, or nil for intermediate
88
+ # @return [Hash]
89
+ def to_schema(provider = nil)
90
+ case provider&.to_sym
91
+ when :anthropic
92
+ { name: tool_name, description: description.to_s, input_schema: json_schema }
93
+ when :openai
94
+ {
95
+ type: "function",
96
+ function: { name: tool_name, description: description.to_s, parameters: json_schema },
97
+ }
98
+ else
99
+ { name: tool_name, description: description.to_s, parameters: json_schema }
100
+ end
101
+ end
102
+
103
+ # Call this in +inherited+ to propagate discoverable metadata to subclasses.
104
+ # Each class using this module is responsible for calling this in its own
105
+ # +inherited+ hook (alongside any chain-specific super calls).
106
+ def copy_discoverable_state_to(subclass)
107
+ subclass.instance_variable_set(:@tool_params, @tool_params&.dup || [])
108
+ subclass.instance_variable_set(:@required_capabilities, @required_capabilities&.dup || [].freeze)
109
+ subclass.instance_variable_set(:@tool_description, @tool_description)
110
+ end
111
+
112
+ private
113
+
114
+ def json_schema
115
+ required_names = tool_params.select { |p| p[:required] }.map { |p| p[:name].to_s }
116
+ properties = tool_params.each_with_object({}) do |p, h|
117
+ prop = { "type" => JSON_TYPES.fetch(p[:type], "string") }
118
+ prop["description"] = p[:desc] unless p[:desc].empty?
119
+ prop["default"] = p[:default] unless p[:default].nil?
120
+ h[p[:name].to_s] = prop
121
+ end
122
+
123
+ schema = { "type" => "object", "properties" => properties }
124
+ schema["required"] = required_names unless required_names.empty?
125
+ schema
126
+ end
127
+ end
128
+
129
+ # ── Instance — capability-guarded call ──────────────────────────────────
130
+
131
+ # Verify the agent has all required capabilities, then invoke +#call+.
132
+ # Called by +LLM::Executor+ during the tool-use loop for every tool/skill invocation.
133
+ #
134
+ # @param allowed_capabilities [Array<Symbol>] capabilities the calling agent has
135
+ # @raise [Igniter::Tool::CapabilityError] if a required capability is missing
136
+ def call_with_capability_check!(allowed_capabilities:, **kwargs)
137
+ required = self.class.required_capabilities
138
+ unless required.empty?
139
+ allowed = allowed_capabilities.map(&:to_sym)
140
+ missing = required.reject { |c| allowed.include?(c) }
141
+ unless missing.empty?
142
+ raise Igniter::Tool::CapabilityError,
143
+ "Tool #{self.class.tool_name.inspect} requires capabilities " \
144
+ "#{missing.inspect} but agent only has #{allowed.inspect}"
145
+ end
146
+ end
147
+ call(**kwargs)
148
+ end
149
+ end
150
+ end
151
+ end