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,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
|