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,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # Decomposes a goal into an ordered sequence of steps and executes them.
6
+ #
7
+ # Implements a lightweight ReAct-style planning loop:
8
+ # 1. +:plan+ — decompose a goal into steps (LLM or rule-based fallback)
9
+ # 2. +:execute_next+ — execute one step and advance the cursor
10
+ # 3. +:run_to_completion+ — plan + execute all steps in a single call
11
+ #
12
+ # == Step decomposition
13
+ #
14
+ # When a +planner:+ callable is configured it receives +goal:+ and
15
+ # +context:+ and must return one of:
16
+ # * +Array<String>+ — one element per step description
17
+ # * +String+ — newline-separated or numbered list; parsed automatically
18
+ # * +Hash+ with +:steps+ key
19
+ #
20
+ # Without a planner the goal itself becomes a single-step plan.
21
+ #
22
+ # == Step execution
23
+ #
24
+ # When a +step_handler:+ callable is configured it receives:
25
+ # step: [String] — step description
26
+ # index: [Integer] — zero-based step index
27
+ # context: [Hash] — shared execution context
28
+ # results: [Array] — results from previous steps
29
+ #
30
+ # Without a handler, steps are marked :skipped.
31
+ #
32
+ # @example
33
+ # planner = ->(goal:, context:) { MyDecomposerSkill.call(goal: goal).steps }
34
+ # executor = ->(step:, index:, context:, results:) { RunStep.call(step) }
35
+ #
36
+ # ref = PlannerAgent.start(initial_state: {
37
+ # planner: planner,
38
+ # step_handler: executor
39
+ # })
40
+ # ref.send(:run_to_completion, goal: "Build a landing page", context: { tone: :casual })
41
+ # puts ref.call(:status).inspect
42
+ class PlannerAgent < Igniter::Agent
43
+ # Immutable step record.
44
+ Step = Struct.new(:index, :description, :status, :result, keyword_init: true)
45
+
46
+ # Returned by the sync :status query.
47
+ PlanStatus = Struct.new(:goal, :total_steps, :current_step,
48
+ :completed, :failed, keyword_init: true)
49
+
50
+ initial_state planner: nil, step_handler: nil, plan: [], current_step: 0,
51
+ goal: nil, context: {}, results: []
52
+
53
+ # Decompose a goal into a plan.
54
+ # Replaces any existing plan; resets cursor and results.
55
+ #
56
+ # Payload keys:
57
+ # goal [String] — required
58
+ # context [Hash] — shared context forwarded to all steps (default: {})
59
+ # planner [#call, nil] — override state planner
60
+ # step_handler [#call, nil] — set/override step handler
61
+ on :plan do |state:, payload:|
62
+ agent = new
63
+ agent.send(:create_plan, state, payload)
64
+ end
65
+
66
+ # Execute the next pending step.
67
+ # No-op when the plan is complete or no plan has been created.
68
+ #
69
+ # Payload keys:
70
+ # step_handler [#call, nil] — override state step_handler for this call
71
+ on :execute_next do |state:, payload:|
72
+ agent = new
73
+ agent.send(:execute_one_step, state, payload)
74
+ end
75
+
76
+ # Plan and execute all steps in one call (blocks until done).
77
+ #
78
+ # Accepts the same payload as :plan plus any :execute_next overrides.
79
+ on :run_to_completion do |state:, payload:|
80
+ agent = new
81
+ agent.send(:run_all, state, payload)
82
+ end
83
+
84
+ # Sync query — current plan progress.
85
+ #
86
+ # @return [PlanStatus]
87
+ on :status do |state:, **|
88
+ PlanStatus.new(
89
+ goal: state[:goal],
90
+ total_steps: state[:plan].size,
91
+ current_step: state[:current_step],
92
+ completed: state[:plan].count { |s| s.status == :done },
93
+ failed: state[:plan].count { |s| s.status == :failed }
94
+ )
95
+ end
96
+
97
+ # Sync query — step results from the last run.
98
+ #
99
+ # @return [Array<Hash>]
100
+ on :results do |state:, **|
101
+ state[:results]
102
+ end
103
+
104
+ # Clear plan, cursor, and results.
105
+ on :reset do |state:, **|
106
+ state.merge(plan: [], current_step: 0, goal: nil, results: [])
107
+ end
108
+
109
+ # Update planner and/or step_handler.
110
+ #
111
+ # Payload keys:
112
+ # planner [#call]
113
+ # step_handler [#call]
114
+ on :configure do |state:, payload:|
115
+ state.merge(
116
+ planner: payload.fetch(:planner, state[:planner]),
117
+ step_handler: payload.fetch(:step_handler, state[:step_handler])
118
+ )
119
+ end
120
+
121
+ private
122
+
123
+ def create_plan(state, payload)
124
+ goal = payload.fetch(:goal)
125
+ context = payload.fetch(:context, state[:context])
126
+ planner = payload.fetch(:planner, state[:planner])
127
+ step_handler = payload.fetch(:step_handler, state[:step_handler])
128
+
129
+ descriptions = planner ? decompose_with_planner(planner, goal, context)
130
+ : [goal.to_s]
131
+
132
+ plan = descriptions.each_with_index.map do |desc, i|
133
+ Step.new(index: i, description: desc, status: :pending, result: nil)
134
+ end
135
+
136
+ state.merge(
137
+ goal: goal,
138
+ context: context,
139
+ plan: plan,
140
+ current_step: 0,
141
+ results: [],
142
+ step_handler: step_handler || state[:step_handler]
143
+ )
144
+ end
145
+
146
+ def execute_one_step(state, payload)
147
+ idx = state[:current_step]
148
+ plan = state[:plan]
149
+ return state if idx >= plan.size
150
+
151
+ step = plan[idx]
152
+ step_handler = payload.fetch(:step_handler, state[:step_handler])
153
+
154
+ result, status = run_step(step_handler, step, state)
155
+
156
+ updated_plan = plan.dup
157
+ updated_plan[idx] = Step.new(
158
+ index: step.index,
159
+ description: step.description,
160
+ status: status,
161
+ result: result
162
+ )
163
+
164
+ state.merge(
165
+ plan: updated_plan,
166
+ current_step: idx + 1,
167
+ results: state[:results] + [{ step: step.description, result: result, status: status }]
168
+ )
169
+ end
170
+
171
+ def run_all(state, payload)
172
+ planned = create_plan(state, payload)
173
+ planned[:plan].size.times.reduce(planned) { |s, _| execute_one_step(s, payload) }
174
+ end
175
+
176
+ # @return [[result, status]]
177
+ def run_step(step_handler, step, state)
178
+ return ["No handler configured", :skipped] unless step_handler
179
+
180
+ result = step_handler.call(
181
+ step: step.description,
182
+ index: step.index,
183
+ context: state[:context],
184
+ results: state[:results]
185
+ )
186
+ [result, :done]
187
+ rescue StandardError => e
188
+ [e.message, :failed]
189
+ end
190
+
191
+ # Parse planner output into Array<String> step descriptions.
192
+ def decompose_with_planner(planner, goal, context)
193
+ raw = planner.call(goal: goal, context: context)
194
+ case raw
195
+ when Array then raw.map(&:to_s).reject(&:empty?)
196
+ when Hash then Array(raw[:steps] || raw["steps"]).map(&:to_s).reject(&:empty?)
197
+ when String then parse_step_list(raw)
198
+ else [raw.to_s]
199
+ end
200
+ end
201
+
202
+ # Split a numbered or newline-delimited string into step descriptions.
203
+ def parse_step_list(text)
204
+ text.split("\n")
205
+ .map { |l| l.sub(/\A\s*\d+[\.\)\-]\s*/, "").strip }
206
+ .reject(&:empty?)
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # Classifies incoming tasks by intent and dispatches to registered handlers.
6
+ #
7
+ # Two classification modes:
8
+ # * **Keyword** (default) — checks whether the task string contains the
9
+ # intent name (case-insensitive). Zero external dependencies.
10
+ # * **LLM-assisted** — uses an Igniter LLM executor to classify; falls back
11
+ # to keyword mode when the LLM returns an unrecognised intent.
12
+ #
13
+ # Handlers are callables that receive +task:+, +intent:+, and +context:+.
14
+ # A fallback handler can be registered for unmatched tasks.
15
+ #
16
+ # @example Keyword routing
17
+ # ref = RouterAgent.start
18
+ # ref.send(:register_route, intent: :refund, handler: RefundSkill.new)
19
+ # ref.send(:register_route, intent: :shipping, handler: ShippingSkill.new)
20
+ # ref.send(:set_fallback, handler: ->(task:, **) { puts "Unknown: #{task}" })
21
+ # ref.send(:route, task: "I want a refund", context: { user_id: 42 })
22
+ #
23
+ # @example LLM routing
24
+ # ref = RouterAgent.start
25
+ # ref.send(:configure_llm, executor: MyLLMClassifier.new)
26
+ # ref.send(:register_route, intent: :billing, handler: BillingSkill.new)
27
+ # ref.send(:route, task: "charge me for the premium plan")
28
+ class RouterAgent < Igniter::Agent
29
+ # Returned by :routes sync query.
30
+ RouteInfo = Struct.new(:intent, :handler_class, keyword_init: true)
31
+
32
+ initial_state routes: {}, fallback_handler: nil, llm: nil
33
+
34
+ # Register a handler for a named intent.
35
+ #
36
+ # Payload keys:
37
+ # intent [Symbol, String] — intent identifier
38
+ # handler [#call] — receives (task:, intent:, context:)
39
+ on :register_route do |state:, payload:|
40
+ intent = payload.fetch(:intent).to_sym
41
+ handler = payload.fetch(:handler)
42
+ state.merge(routes: state[:routes].merge(intent => handler))
43
+ end
44
+
45
+ # Remove a route.
46
+ #
47
+ # Payload keys:
48
+ # intent [Symbol, String]
49
+ on :remove_route do |state:, payload:|
50
+ intent = payload.fetch(:intent).to_sym
51
+ state.merge(routes: state[:routes].reject { |k, _| k == intent })
52
+ end
53
+
54
+ # Set the fallback handler for unmatched tasks.
55
+ #
56
+ # Payload keys:
57
+ # handler [#call] — receives (task:, intent:, context:)
58
+ on :set_fallback do |state:, payload:|
59
+ state.merge(fallback_handler: payload.fetch(:handler))
60
+ end
61
+
62
+ # Configure an LLM executor for intent classification.
63
+ # The executor must respond to :call and receive a Hash with:
64
+ # task:, context:, intents: (Array<String> of registered intent names)
65
+ # It must return a Hash with :intent key (String or Symbol).
66
+ #
67
+ # Payload keys:
68
+ # executor [#call]
69
+ on :configure_llm do |state:, payload:|
70
+ state.merge(llm: payload.fetch(:executor))
71
+ end
72
+
73
+ # Route a task to the appropriate handler.
74
+ #
75
+ # Payload keys:
76
+ # task [String] — the task or query to route
77
+ # context [Hash] — additional context forwarded to the handler
78
+ # on_unrouted [#call, nil] — called with (task:, intent:) when no handler found
79
+ on :route do |state:, payload:|
80
+ agent = new
81
+ agent.send(:dispatch, payload, state)
82
+ state
83
+ end
84
+
85
+ # Sync query — list registered intents.
86
+ #
87
+ # @return [Array<RouteInfo>]
88
+ on :routes do |state:, **|
89
+ state[:routes].map { |intent, handler|
90
+ RouteInfo.new(intent: intent, handler_class: handler.class.name)
91
+ }
92
+ end
93
+
94
+ private
95
+
96
+ def dispatch(payload, state)
97
+ task = payload.fetch(:task)
98
+ context = payload.fetch(:context, {})
99
+ on_unrouted = payload[:on_unrouted] || state[:on_unrouted]
100
+ routes = state[:routes]
101
+ llm = state[:llm]
102
+
103
+ intent = llm ? classify_llm(task, context, routes, llm) : nil
104
+ intent = classify_keyword(task, routes) if intent.nil? || !routes.key?(intent)
105
+ handler = routes[intent] || state[:fallback_handler]
106
+
107
+ if handler
108
+ handler.call(task: task, intent: intent, context: context)
109
+ elsif on_unrouted
110
+ on_unrouted.call(task: task, intent: intent)
111
+ end
112
+ end
113
+
114
+ def classify_llm(task, context, routes, llm)
115
+ result = llm.call(
116
+ task: task,
117
+ context: context,
118
+ intents: routes.keys.map(&:to_s)
119
+ )
120
+ result[:intent]&.to_sym
121
+ rescue StandardError
122
+ nil # fall through to keyword classification
123
+ end
124
+
125
+ def classify_keyword(task, routes)
126
+ task_lower = task.to_s.downcase
127
+ routes.keys.find { |intent| task_lower.include?(intent.to_s.downcase) }
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # Reflects on a rolling window of past action episodes, surfaces patterns
6
+ # in failures and successes, and proposes behavioural patches.
7
+ #
8
+ # Two reflection modes:
9
+ # * **Heuristic** (default) — computes success rate and top failing actions;
10
+ # requires no external dependencies.
11
+ # * **LLM-assisted** — delegates to any callable that accepts
12
+ # +reflection_prompt: String+ and returns a String summary.
13
+ #
14
+ # @example Record episodes and reflect
15
+ # ref = SelfReflectionAgent.start
16
+ # ref.send(:record_episode, action: :process_order, outcome: :success)
17
+ # ref.send(:record_episode, action: :send_email, outcome: :failure, details: { reason: "timeout" })
18
+ # ref.send(:reflect)
19
+ # status = ref.call(:status) # => StatusInfo struct
20
+ # recs = ref.call(:reflections)
21
+ class SelfReflectionAgent < Igniter::Agent
22
+ # Sync-query return type.
23
+ StatusInfo = Struct.new(:episodes, :reflections, :patches_applied,
24
+ :last_reflected_at, keyword_init: true)
25
+
26
+ # A single recorded activity.
27
+ Episode = Struct.new(:action, :outcome, :details, :occurred_at, keyword_init: true)
28
+
29
+ # One completed reflection cycle.
30
+ ReflectionRecord = Struct.new(:summary, :insights, :patch,
31
+ :reflected_at, keyword_init: true)
32
+
33
+ initial_state \
34
+ episodes: [],
35
+ reflections: [],
36
+ patches: [],
37
+ llm: nil,
38
+ window: 50,
39
+ patches_applied: 0
40
+
41
+ # Record a single action outcome.
42
+ #
43
+ # Payload keys:
44
+ # action [Symbol, String] — name of the action performed
45
+ # outcome [Symbol] — :success | :failure | any meaningful symbol
46
+ # details [Hash] — optional extra context
47
+ on :record_episode do |state:, payload:|
48
+ ep = Episode.new(
49
+ action: payload.fetch(:action),
50
+ outcome: payload.fetch(:outcome),
51
+ details: payload.fetch(:details, {}),
52
+ occurred_at: Time.now
53
+ )
54
+ # keep at most 2× the reflection window to avoid unbounded growth
55
+ kept = (state[:episodes] + [ep]).last(state[:window] * 2)
56
+ state.merge(episodes: kept)
57
+ end
58
+
59
+ # Run a reflection cycle over the latest +window+ episodes.
60
+ # Appends a ReflectionRecord to the reflection log.
61
+ on :reflect do |state:, payload:|
62
+ agent = new
63
+ rec = agent.send(:run_reflection, state, payload || {})
64
+ state.merge(reflections: state[:reflections] + [rec])
65
+ end
66
+
67
+ # Store an externally generated or LLM-proposed behavioural patch.
68
+ #
69
+ # Payload keys:
70
+ # patch [String] — description of the proposed change
71
+ on :apply_patch do |state:, payload:|
72
+ entry = { patch: payload.fetch(:patch), applied_at: Time.now }
73
+ patches = state[:patches] + [entry]
74
+ state.merge(patches: patches, patches_applied: state[:patches_applied] + 1)
75
+ end
76
+
77
+ # Sync query — current operational summary.
78
+ #
79
+ # @return [StatusInfo]
80
+ on :status do |state:, **|
81
+ last_r = state[:reflections].last
82
+ StatusInfo.new(
83
+ episodes: state[:episodes].size,
84
+ reflections: state[:reflections].size,
85
+ patches_applied: state[:patches_applied],
86
+ last_reflected_at: last_r&.reflected_at
87
+ )
88
+ end
89
+
90
+ # Sync query — all ReflectionRecord objects.
91
+ on :reflections do |state:, **|
92
+ state[:reflections].dup
93
+ end
94
+
95
+ # Sync query — all recorded Episode objects.
96
+ on :episodes do |state:, **|
97
+ state[:episodes].dup
98
+ end
99
+
100
+ # Update agent configuration.
101
+ #
102
+ # Payload keys:
103
+ # llm [#call, nil] — optional LLM callable
104
+ # window [Integer] — number of recent episodes to reflect on
105
+ on :configure do |state:, payload:|
106
+ state.merge(payload.slice(:llm, :window).compact)
107
+ end
108
+
109
+ # Clear all recorded state (does not reset configuration).
110
+ on :reset do |state:, **|
111
+ state.merge(episodes: [], reflections: [], patches: [], patches_applied: 0)
112
+ end
113
+
114
+ private
115
+
116
+ def run_reflection(state, _payload)
117
+ episodes = state[:episodes].last(state[:window])
118
+ llm = state[:llm]
119
+
120
+ summary, insights, patch =
121
+ if llm
122
+ reflect_with_llm(llm, episodes)
123
+ else
124
+ reflect_heuristic(episodes)
125
+ end
126
+
127
+ ReflectionRecord.new(
128
+ summary: summary,
129
+ insights: insights,
130
+ patch: patch,
131
+ reflected_at: Time.now
132
+ )
133
+ end
134
+
135
+ def reflect_with_llm(llm, episodes)
136
+ successes = episodes.count { |e| [:success, "success"].include?(e.outcome) }
137
+ failures = episodes.count { |e| [:failure, "failure"].include?(e.outcome) }
138
+ digest = episodes.map { |e| "#{e.action}:#{e.outcome}" }.join(", ")
139
+
140
+ prompt = "Reflect on #{episodes.size} recent episodes " \
141
+ "(#{successes} succeeded, #{failures} failed): #{digest}. " \
142
+ "Provide a brief summary, up to 3 key insights, " \
143
+ "and one suggested behavioural patch."
144
+
145
+ summary = llm.call(reflection_prompt: prompt).to_s
146
+ [summary, [], nil]
147
+ rescue StandardError
148
+ reflect_heuristic(episodes)
149
+ end
150
+
151
+ def reflect_heuristic(episodes)
152
+ return ["No episodes to reflect on.", [], nil] if episodes.empty?
153
+
154
+ total = episodes.size
155
+ failures = episodes.select { |e| [:failure, "failure"].include?(e.outcome) }
156
+ rate = ((total - failures.size).to_f / total * 100).round(1)
157
+
158
+ top_failing = failures.map(&:action).tally
159
+ .sort_by { |_, c| -c }
160
+ .first(3)
161
+
162
+ insights = ["Success rate: #{rate}%"]
163
+ unless top_failing.empty?
164
+ insights << "Top failing actions: #{top_failing.map { |a, c| "#{a}(×#{c})" }.join(", ")}"
165
+ end
166
+
167
+ patch = if rate < 50
168
+ "Consider retries or simplification for: #{top_failing.map(&:first).join(", ")}"
169
+ end
170
+
171
+ ["Reflected on #{total} episodes. #{rate}% success rate.", insights, patch]
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Agents
5
+ # In-process metrics collection with Prometheus text export.
6
+ #
7
+ # Supports three metric types:
8
+ # * **counter** — monotonically increasing value (:increment)
9
+ # * **gauge** — arbitrary current value (:gauge)
10
+ # * **histogram** — observed value distribution (:observe)
11
+ #
12
+ # All metric names are coerced to strings. Tags (labels) are stored but not
13
+ # yet aggregated — they are included in the snapshot for external processing.
14
+ #
15
+ # @example
16
+ # ref = MetricsAgent.start
17
+ # ref.send(:increment, name: "http.requests", by: 1, tags: { method: "GET" })
18
+ # ref.send(:gauge, name: "queue.depth", value: 42)
19
+ # ref.send(:observe, name: "response_time", value: 0.123)
20
+ #
21
+ # snap = ref.call(:snapshot)
22
+ # puts snap.counters["http.requests"] # => 1.0
23
+ #
24
+ # puts ref.call(:prometheus_text)
25
+ class MetricsAgent < Igniter::Agent
26
+ # Returned by the sync :snapshot query.
27
+ Snapshot = Struct.new(:counters, :gauges, :histograms, keyword_init: true)
28
+
29
+ initial_state counters: {}, gauges: {}, histograms: {}
30
+
31
+ # Increment a counter.
32
+ #
33
+ # Payload keys:
34
+ # name [String, Symbol] — metric name
35
+ # by [Numeric] — increment amount (default: 1)
36
+ # tags [Hash] — labels (stored, not aggregated)
37
+ on :increment do |state:, payload:|
38
+ name = payload.fetch(:name).to_s
39
+ by = payload.fetch(:by, 1).to_f
40
+ counters = state[:counters].dup
41
+ counters[name] = (counters[name] || 0.0) + by
42
+ state.merge(counters: counters)
43
+ end
44
+
45
+ # Set a gauge to an exact value.
46
+ #
47
+ # Payload keys:
48
+ # name [String, Symbol]
49
+ # value [Numeric]
50
+ # tags [Hash]
51
+ on :gauge do |state:, payload:|
52
+ name = payload.fetch(:name).to_s
53
+ value = payload.fetch(:value).to_f
54
+ gauges = state[:gauges].merge(name => value)
55
+ state.merge(gauges: gauges)
56
+ end
57
+
58
+ # Record a histogram observation.
59
+ #
60
+ # Payload keys:
61
+ # name [String, Symbol]
62
+ # value [Numeric]
63
+ on :observe do |state:, payload:|
64
+ name = payload.fetch(:name).to_s
65
+ value = payload.fetch(:value).to_f
66
+ histograms = state[:histograms].dup
67
+ bucket = histograms[name] || { count: 0, sum: 0.0, min: Float::INFINITY,
68
+ max: -Float::INFINITY, values: [] }
69
+ updated = bucket.merge(
70
+ count: bucket[:count] + 1,
71
+ sum: bucket[:sum] + value,
72
+ min: [bucket[:min], value].min,
73
+ max: [bucket[:max], value].max,
74
+ values: bucket[:values] + [value]
75
+ )
76
+ state.merge(histograms: histograms.merge(name => updated))
77
+ end
78
+
79
+ # Sync snapshot query — returns all current metric values.
80
+ #
81
+ # @return [Snapshot]
82
+ on :snapshot do |state:, **|
83
+ Snapshot.new(
84
+ counters: state[:counters].dup,
85
+ gauges: state[:gauges].dup,
86
+ histograms: state[:histograms].transform_values { |h|
87
+ { count: h[:count], sum: h[:sum], min: h[:min], max: h[:max],
88
+ avg: h[:count] > 0 ? h[:sum] / h[:count] : 0.0 }
89
+ }
90
+ )
91
+ end
92
+
93
+ # Sync query — render metrics in Prometheus text format.
94
+ #
95
+ # @return [String]
96
+ on :prometheus_text do |state:, **|
97
+ render_prometheus(state)
98
+ end
99
+
100
+ # Reset all metrics.
101
+ on :reset do |state:, **|
102
+ state.merge(counters: {}, gauges: {}, histograms: {})
103
+ end
104
+
105
+ class << self
106
+ # Render state as Prometheus exposition format.
107
+ #
108
+ # @param state [Hash]
109
+ # @return [String]
110
+ def render_prometheus(state)
111
+ lines = []
112
+ state[:counters].each do |name, value|
113
+ lines << "# TYPE #{name} counter"
114
+ lines << "#{name} #{value}"
115
+ end
116
+ state[:gauges].each do |name, value|
117
+ lines << "# TYPE #{name} gauge"
118
+ lines << "#{name} #{value}"
119
+ end
120
+ state[:histograms].each do |name, h|
121
+ lines << "# TYPE #{name} histogram"
122
+ lines << "#{name}_count #{h[:count]}"
123
+ lines << "#{name}_sum #{h[:sum]}"
124
+ end
125
+ lines.join("\n")
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end