spurline-docs 0.3.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 +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +160 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module Tools
|
|
6
|
+
# Base class for all Spurline tools. Tools are atomic — they cannot invoke
|
|
7
|
+
# other tools (ADR-003). Composition belongs in the Skill layer.
|
|
8
|
+
#
|
|
9
|
+
# Subclasses must implement #call with keyword arguments matching
|
|
10
|
+
# the tool's parameter schema.
|
|
11
|
+
class Base
|
|
12
|
+
class << self
|
|
13
|
+
def tool_name(name = nil)
|
|
14
|
+
if name
|
|
15
|
+
@tool_name = name.to_sym
|
|
16
|
+
else
|
|
17
|
+
@tool_name || self.name&.split("::")&.last&.gsub(/([a-z])([A-Z])/, '\1_\2')&.downcase&.to_sym
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def description(desc = nil)
|
|
22
|
+
if desc
|
|
23
|
+
@description = desc
|
|
24
|
+
else
|
|
25
|
+
@description || ""
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parameters(params = nil)
|
|
30
|
+
if params
|
|
31
|
+
@parameters = params
|
|
32
|
+
else
|
|
33
|
+
@parameters || {}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Declares a secret this tool needs injected at execution time.
|
|
38
|
+
# Secrets are resolved by the framework and are not part of the tool schema.
|
|
39
|
+
def secret(name, description: nil)
|
|
40
|
+
@declared_secrets ||= []
|
|
41
|
+
@declared_secrets << { name: name.to_sym, description: description }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns all declared secrets for this class, including inherited ones.
|
|
45
|
+
def declared_secrets
|
|
46
|
+
own = @declared_secrets || []
|
|
47
|
+
if superclass.respond_to?(:declared_secrets)
|
|
48
|
+
superclass.declared_secrets + own
|
|
49
|
+
else
|
|
50
|
+
own
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns sensitive argument names from schema metadata and declared secrets.
|
|
55
|
+
#
|
|
56
|
+
# A parameter is treated as sensitive when its schema property includes:
|
|
57
|
+
# sensitive: true
|
|
58
|
+
def sensitive_parameters
|
|
59
|
+
schema = parameters || {}
|
|
60
|
+
properties = schema[:properties] || schema["properties"] || {}
|
|
61
|
+
schema_sensitive = Set.new
|
|
62
|
+
if properties.is_a?(Hash)
|
|
63
|
+
properties.each do |name, definition|
|
|
64
|
+
next unless definition.is_a?(Hash)
|
|
65
|
+
|
|
66
|
+
sensitive = definition[:sensitive]
|
|
67
|
+
sensitive = definition["sensitive"] if sensitive.nil?
|
|
68
|
+
schema_sensitive << name.to_sym if sensitive
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
secret_names = (declared_secrets || []).map { |secret| secret[:name] }
|
|
73
|
+
schema_sensitive | Set.new(secret_names)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Declares that this tool requires confirmation before execution.
|
|
77
|
+
def requires_confirmation(val = true)
|
|
78
|
+
@requires_confirmation = val
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def requires_confirmation?
|
|
82
|
+
return @requires_confirmation unless @requires_confirmation.nil?
|
|
83
|
+
|
|
84
|
+
return superclass.requires_confirmation? if superclass.respond_to?(:requires_confirmation?)
|
|
85
|
+
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Declares a timeout in seconds for tool execution.
|
|
90
|
+
def timeout(seconds = nil)
|
|
91
|
+
unless seconds.nil?
|
|
92
|
+
@timeout = seconds
|
|
93
|
+
else
|
|
94
|
+
@timeout
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Declares that tool calls are idempotent and can be cached by key.
|
|
99
|
+
def idempotent(val = true)
|
|
100
|
+
@idempotent = val
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def idempotent?
|
|
104
|
+
return @idempotent unless @idempotent.nil?
|
|
105
|
+
|
|
106
|
+
return superclass.idempotent? if superclass.respond_to?(:idempotent?)
|
|
107
|
+
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Declares which params form the idempotency key.
|
|
112
|
+
def idempotency_key(*params)
|
|
113
|
+
@idempotency_key_params = params.flatten.map(&:to_sym)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def idempotency_key_params
|
|
117
|
+
return @idempotency_key_params if instance_variable_defined?(:@idempotency_key_params)
|
|
118
|
+
|
|
119
|
+
return superclass.idempotency_key_params if superclass.respond_to?(:idempotency_key_params)
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Declares cache TTL (seconds) for idempotent results.
|
|
125
|
+
def idempotency_ttl(seconds = nil)
|
|
126
|
+
unless seconds.nil?
|
|
127
|
+
@idempotency_ttl = seconds
|
|
128
|
+
end
|
|
129
|
+
@idempotency_ttl
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def idempotency_ttl_value
|
|
133
|
+
ttl = idempotency_ttl
|
|
134
|
+
return ttl unless ttl.nil?
|
|
135
|
+
|
|
136
|
+
if superclass.respond_to?(:idempotency_ttl_value)
|
|
137
|
+
return superclass.idempotency_ttl_value
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
default_ttl = Spurline.config.idempotency_default_ttl
|
|
141
|
+
return default_ttl unless default_ttl.nil?
|
|
142
|
+
|
|
143
|
+
Spurline::Tools::Idempotency::Ledger::DEFAULT_TTL
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Declares a custom key lambda taking the final args hash.
|
|
147
|
+
def idempotency_key_fn(fn = nil)
|
|
148
|
+
@idempotency_key_fn = fn if fn
|
|
149
|
+
@idempotency_key_fn
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Declares that tool expects injected _scope keyword argument.
|
|
153
|
+
def scoped(val = true)
|
|
154
|
+
@scoped = val
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def scoped?
|
|
158
|
+
return @scoped unless @scoped.nil?
|
|
159
|
+
|
|
160
|
+
return superclass.scoped? if superclass.respond_to?(:scoped?)
|
|
161
|
+
|
|
162
|
+
false
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Validates arguments against the tool's parameter schema.
|
|
166
|
+
# Checks required properties and type mismatches.
|
|
167
|
+
# Returns true if valid, raises ConfigurationError if invalid.
|
|
168
|
+
def validate_arguments!(args)
|
|
169
|
+
schema = parameters
|
|
170
|
+
return true if schema.empty?
|
|
171
|
+
|
|
172
|
+
properties = schema[:properties] || schema["properties"] || {}
|
|
173
|
+
required = schema[:required] || schema["required"] || []
|
|
174
|
+
|
|
175
|
+
# Check required properties
|
|
176
|
+
required.each do |prop|
|
|
177
|
+
prop_sym = prop.to_sym
|
|
178
|
+
unless args.key?(prop_sym) || args.key?(prop.to_s)
|
|
179
|
+
raise Spurline::ConfigurationError,
|
|
180
|
+
"Tool '#{tool_name}' missing required parameter '#{prop}'. " \
|
|
181
|
+
"Required parameters: #{required.join(", ")}."
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
true
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def name
|
|
190
|
+
self.class.tool_name
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def call(**_args)
|
|
194
|
+
raise NotImplementedError,
|
|
195
|
+
"#{self.class.name} must implement #call. Tools are leaf nodes (ADR-003) — " \
|
|
196
|
+
"they receive arguments and return a result. Use a Spurline::Skill for composition."
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Returns the tool schema for the LLM adapter.
|
|
200
|
+
def to_schema
|
|
201
|
+
{
|
|
202
|
+
name: name,
|
|
203
|
+
description: self.class.description,
|
|
204
|
+
input_schema: self.class.parameters,
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Spurline
|
|
7
|
+
module Tools
|
|
8
|
+
module Idempotency
|
|
9
|
+
# Computes idempotency keys from tool name and arguments.
|
|
10
|
+
class KeyComputer
|
|
11
|
+
# Computes a deterministic key for a tool call.
|
|
12
|
+
#
|
|
13
|
+
# @param tool_name [Symbol] Tool identifier
|
|
14
|
+
# @param args [Hash] Tool call arguments
|
|
15
|
+
# @param key_params [Array<Symbol>, nil] Specific params to include (nil = all)
|
|
16
|
+
# @param key_fn [Proc, nil] Custom key computation lambda
|
|
17
|
+
# @return [String] Deterministic key string "tool_name:hash"
|
|
18
|
+
#
|
|
19
|
+
# Key computation:
|
|
20
|
+
# 1. If key_fn provided: "#{tool_name}:#{key_fn.call(args)}"
|
|
21
|
+
# 2. If key_params provided: SHA256 of only those params
|
|
22
|
+
# 3. Default: SHA256 of all args (canonical JSON with sorted keys)
|
|
23
|
+
def self.compute(tool_name:, args:, key_params: nil, key_fn: nil)
|
|
24
|
+
prefix = tool_name.to_s
|
|
25
|
+
|
|
26
|
+
hash = if key_fn
|
|
27
|
+
key_fn.call(args).to_s
|
|
28
|
+
elsif key_params
|
|
29
|
+
canonical_hash(args.slice(*key_params))
|
|
30
|
+
else
|
|
31
|
+
canonical_hash(args)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
"#{prefix}:#{hash}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Produces a deterministic hash of arguments.
|
|
38
|
+
# Sorts keys recursively for canonical representation.
|
|
39
|
+
def self.canonical_hash(args)
|
|
40
|
+
json = JSON.generate(canonicalize(args))
|
|
41
|
+
Digest::SHA256.hexdigest(json)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Recursively sorts hash keys for deterministic serialization.
|
|
45
|
+
def self.canonicalize(obj)
|
|
46
|
+
case obj
|
|
47
|
+
when Hash
|
|
48
|
+
obj.sort_by { |k, _| k.to_s }.map { |k, v| [k.to_s, canonicalize(v)] }.to_h
|
|
49
|
+
when Array
|
|
50
|
+
obj.map { |v| canonicalize(v) }
|
|
51
|
+
else
|
|
52
|
+
obj
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Session-scoped cache for idempotent tool results.
|
|
58
|
+
# Wraps a plain hash (from session.metadata[:idempotency_ledger]).
|
|
59
|
+
class Ledger
|
|
60
|
+
DEFAULT_TTL = 86_400 # 24 hours
|
|
61
|
+
|
|
62
|
+
# @param store [Hash] The backing hash (from session.metadata)
|
|
63
|
+
def initialize(store)
|
|
64
|
+
@store = store
|
|
65
|
+
@store[:entries] ||= {}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns true if key exists and is not expired.
|
|
69
|
+
#
|
|
70
|
+
# @param key [String] Idempotency key
|
|
71
|
+
# @param ttl [Integer] TTL in seconds
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
def cached?(key, ttl: DEFAULT_TTL)
|
|
74
|
+
entry = @store[:entries][key]
|
|
75
|
+
return false unless entry
|
|
76
|
+
|
|
77
|
+
age = Time.now.to_f - entry[:timestamp]
|
|
78
|
+
if age > ttl
|
|
79
|
+
@store[:entries].delete(key) # Lazy cleanup
|
|
80
|
+
false
|
|
81
|
+
else
|
|
82
|
+
true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns the cached result, or nil if not cached/expired.
|
|
87
|
+
#
|
|
88
|
+
# @param key [String] Idempotency key
|
|
89
|
+
# @param ttl [Integer] TTL in seconds
|
|
90
|
+
# @return [String, nil]
|
|
91
|
+
def fetch(key, ttl: DEFAULT_TTL)
|
|
92
|
+
return nil unless cached?(key, ttl: ttl)
|
|
93
|
+
|
|
94
|
+
@store[:entries][key][:result]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Stores a result with timestamp.
|
|
98
|
+
#
|
|
99
|
+
# @param key [String] Idempotency key
|
|
100
|
+
# @param result [String] Serialized tool result
|
|
101
|
+
# @param args_hash [String] Hash of the arguments (for conflict detection)
|
|
102
|
+
# @param ttl [Integer] TTL in seconds (stored for reference)
|
|
103
|
+
def store!(key, result:, args_hash:, ttl: DEFAULT_TTL)
|
|
104
|
+
@store[:entries][key] = {
|
|
105
|
+
result: result,
|
|
106
|
+
args_hash: args_hash,
|
|
107
|
+
timestamp: Time.now.to_f,
|
|
108
|
+
ttl: ttl,
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Returns true if key exists with different arguments.
|
|
113
|
+
# Same key + different args = bug in calling code.
|
|
114
|
+
#
|
|
115
|
+
# @param key [String] Idempotency key
|
|
116
|
+
# @param args_hash [String] Hash of current arguments
|
|
117
|
+
# @return [Boolean]
|
|
118
|
+
def conflict?(key, args_hash)
|
|
119
|
+
entry = @store[:entries][key]
|
|
120
|
+
return false unless entry
|
|
121
|
+
|
|
122
|
+
entry[:args_hash] != args_hash
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns age of cached entry in milliseconds, or nil.
|
|
126
|
+
def cache_age_ms(key)
|
|
127
|
+
entry = @store[:entries][key]
|
|
128
|
+
return nil unless entry
|
|
129
|
+
|
|
130
|
+
((Time.now.to_f - entry[:timestamp]) * 1000).round
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Removes all expired entries.
|
|
134
|
+
def cleanup_expired!(default_ttl: DEFAULT_TTL)
|
|
135
|
+
now = Time.now.to_f
|
|
136
|
+
@store[:entries].delete_if do |_key, entry|
|
|
137
|
+
ttl = entry[:ttl] || default_ttl
|
|
138
|
+
(now - entry[:timestamp]) > ttl
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Empties the entire ledger.
|
|
143
|
+
def clear!
|
|
144
|
+
@store[:entries] = {}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns the number of entries.
|
|
148
|
+
def size
|
|
149
|
+
@store[:entries].size
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Returns true if the ledger has no entries.
|
|
153
|
+
def empty?
|
|
154
|
+
@store[:entries].empty?
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Per-tool idempotency configuration.
|
|
159
|
+
# Built from class-level declarations or DSL config.
|
|
160
|
+
class Config
|
|
161
|
+
attr_reader :enabled, :key_params, :ttl, :key_fn
|
|
162
|
+
|
|
163
|
+
# @param enabled [Boolean] Whether idempotency is enabled for this tool
|
|
164
|
+
# @param key_params [Array<Symbol>, nil] Which params form the key (nil = all)
|
|
165
|
+
# @param ttl [Integer] TTL in seconds
|
|
166
|
+
# @param key_fn [Proc, nil] Custom key computation lambda
|
|
167
|
+
def initialize(enabled: false, key_params: nil, ttl: Ledger::DEFAULT_TTL, key_fn: nil)
|
|
168
|
+
@enabled = enabled
|
|
169
|
+
@key_params = key_params
|
|
170
|
+
@ttl = ttl
|
|
171
|
+
@key_fn = key_fn
|
|
172
|
+
freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def enabled?
|
|
176
|
+
@enabled
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Builds a Config from a tool class's class-level declarations.
|
|
180
|
+
#
|
|
181
|
+
# @param tool_class [Class] Tool class with idempotency declarations
|
|
182
|
+
# @return [Config]
|
|
183
|
+
def self.from_tool_class(tool_class)
|
|
184
|
+
new(
|
|
185
|
+
enabled: tool_class.respond_to?(:idempotent?) && tool_class.idempotent?,
|
|
186
|
+
key_params: tool_class.respond_to?(:idempotency_key_params) ? tool_class.idempotency_key_params : nil,
|
|
187
|
+
ttl: tool_class.respond_to?(:idempotency_ttl_value) ? tool_class.idempotency_ttl_value : Ledger::DEFAULT_TTL,
|
|
188
|
+
key_fn: tool_class.respond_to?(:idempotency_key_fn) ? tool_class.idempotency_key_fn : nil,
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Builds a Config from DSL options hash.
|
|
193
|
+
# DSL wins on conflict with class declarations.
|
|
194
|
+
#
|
|
195
|
+
# @param dsl_options [Hash] { idempotent: true, idempotency_key: :tx_id, idempotency_ttl: 3600 }
|
|
196
|
+
# @param tool_class [Class, nil] Tool class for fallback values
|
|
197
|
+
# @return [Config]
|
|
198
|
+
def self.from_dsl(dsl_options, tool_class: nil)
|
|
199
|
+
base = tool_class ? from_tool_class(tool_class) : new
|
|
200
|
+
|
|
201
|
+
new(
|
|
202
|
+
enabled: dsl_options.fetch(:idempotent, base.enabled),
|
|
203
|
+
key_params: normalize_key_params(dsl_options.fetch(:idempotency_key, base.key_params)),
|
|
204
|
+
ttl: dsl_options.fetch(:idempotency_ttl, base.ttl),
|
|
205
|
+
key_fn: dsl_options.fetch(:idempotency_key_fn, base.key_fn),
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def self.normalize_key_params(value)
|
|
210
|
+
case value
|
|
211
|
+
when Symbol then [value]
|
|
212
|
+
when Array then value
|
|
213
|
+
when nil then nil
|
|
214
|
+
else [value]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Spurline
|
|
6
|
+
module Tools
|
|
7
|
+
# Loads tool permissions from a YAML file.
|
|
8
|
+
# Expected format:
|
|
9
|
+
#
|
|
10
|
+
# tools:
|
|
11
|
+
# web_search:
|
|
12
|
+
# denied: false
|
|
13
|
+
# allowed_users:
|
|
14
|
+
# - admin
|
|
15
|
+
# - researcher
|
|
16
|
+
# requires_confirmation: true
|
|
17
|
+
# dangerous_tool:
|
|
18
|
+
# denied: true
|
|
19
|
+
#
|
|
20
|
+
# Returns a hash keyed by tool name symbols.
|
|
21
|
+
class Permissions
|
|
22
|
+
def self.load_file(path)
|
|
23
|
+
return {} unless path && File.exist?(path)
|
|
24
|
+
|
|
25
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
|
|
26
|
+
tools = raw["tools"] || raw[:tools] || {}
|
|
27
|
+
|
|
28
|
+
tools.each_with_object({}) do |(name, config), result|
|
|
29
|
+
result[name.to_sym] = symbolize_config(config)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.symbolize_config(config)
|
|
34
|
+
return {} unless config.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
config.each_with_object({}) do |(key, value), result|
|
|
37
|
+
result[key.to_sym] = value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private_class_method :symbolize_config
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Tools
|
|
5
|
+
# Global registry of available tools. Tools register themselves here,
|
|
6
|
+
# either directly or via spur gem auto-registration.
|
|
7
|
+
class Registry
|
|
8
|
+
def initialize
|
|
9
|
+
@tools = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(name, tool_class)
|
|
13
|
+
name = name.to_sym
|
|
14
|
+
@tools[name] = tool_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fetch(name)
|
|
18
|
+
name = name.to_sym
|
|
19
|
+
@tools.fetch(name) do
|
|
20
|
+
raise Spurline::ToolNotFoundError,
|
|
21
|
+
"Tool '#{name}' is not registered. Ensure its spur gem is installed " \
|
|
22
|
+
"and required, or register it manually with Spurline::Tools::Registry#register."
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def registered?(name)
|
|
27
|
+
@tools.key?(name.to_sym)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def all
|
|
31
|
+
@tools.dup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def names
|
|
35
|
+
@tools.keys
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def clear!
|
|
39
|
+
@tools.clear
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|