igniter 0.4.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/incremental.rb +142 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- metadata +128 -1
|
@@ -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
|