spurline-deploy 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 +161 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Memory
|
|
5
|
+
# Orchestrates short-term and long-term memory stores.
|
|
6
|
+
class Manager
|
|
7
|
+
attr_reader :short_term, :long_term, :episodic
|
|
8
|
+
|
|
9
|
+
def initialize(config: {})
|
|
10
|
+
window = config.fetch(:short_term, {}).fetch(:window, ShortTerm::DEFAULT_WINDOW)
|
|
11
|
+
@short_term = ShortTerm.new(window: window)
|
|
12
|
+
@long_term = build_long_term_store(config.fetch(:long_term, nil))
|
|
13
|
+
@episodic = build_episodic_store(config.fetch(:episodic, nil))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_turn(turn)
|
|
17
|
+
evicted_before = short_term.last_evicted
|
|
18
|
+
short_term.add_turn(turn)
|
|
19
|
+
|
|
20
|
+
evicted_turn = short_term.last_evicted
|
|
21
|
+
if long_term && evicted_turn && !evicted_turn.equal?(evicted_before)
|
|
22
|
+
persist_to_long_term!(evicted_turn)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def recent_turns(n = nil)
|
|
27
|
+
short_term.recent(n)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def turn_count
|
|
31
|
+
short_term.size
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def recall(query:, limit: 5)
|
|
35
|
+
return [] unless long_term
|
|
36
|
+
|
|
37
|
+
long_term.retrieve(query: query, limit: limit)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def clear!
|
|
41
|
+
short_term.clear!
|
|
42
|
+
long_term&.clear!
|
|
43
|
+
episodic&.clear!
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether any turns have been evicted from the window.
|
|
47
|
+
# Useful for determining if summarization should kick in.
|
|
48
|
+
def window_overflowed?
|
|
49
|
+
!short_term.last_evicted.nil?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def record_episode(type:, content:, metadata: {}, turn_number: nil, parent_episode_id: nil, timestamp: Time.now)
|
|
53
|
+
return nil unless episodic
|
|
54
|
+
|
|
55
|
+
episodic.record(
|
|
56
|
+
type: type,
|
|
57
|
+
content: content,
|
|
58
|
+
metadata: metadata,
|
|
59
|
+
turn_number: turn_number,
|
|
60
|
+
parent_episode_id: parent_episode_id,
|
|
61
|
+
timestamp: timestamp
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def restore_episodes(serialized_episodes)
|
|
66
|
+
return unless episodic
|
|
67
|
+
|
|
68
|
+
episodic.restore(serialized_episodes)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def build_long_term_store(config)
|
|
74
|
+
return nil unless config
|
|
75
|
+
|
|
76
|
+
adapter = config[:adapter]
|
|
77
|
+
case adapter
|
|
78
|
+
when :postgres
|
|
79
|
+
embedder = build_embedder(config)
|
|
80
|
+
LongTerm::Postgres.new(connection_string: config[:connection_string], embedder: embedder)
|
|
81
|
+
when nil
|
|
82
|
+
nil
|
|
83
|
+
else
|
|
84
|
+
return adapter if adapter.respond_to?(:store) && adapter.respond_to?(:retrieve)
|
|
85
|
+
|
|
86
|
+
raise Spurline::ConfigurationError,
|
|
87
|
+
"Unknown long-term memory adapter: #{adapter.inspect}."
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_embedder(config)
|
|
92
|
+
model = config[:embedding_model] || config[:embedder]
|
|
93
|
+
|
|
94
|
+
case model
|
|
95
|
+
when :openai
|
|
96
|
+
Embedder::OpenAI.new
|
|
97
|
+
when nil
|
|
98
|
+
raise Spurline::ConfigurationError,
|
|
99
|
+
"Long-term memory requires an embedding_model. " \
|
|
100
|
+
"Example: memory :long_term, adapter: :postgres, embedding_model: :openai"
|
|
101
|
+
else
|
|
102
|
+
return model if model.respond_to?(:embed) && model.respond_to?(:dimensions)
|
|
103
|
+
|
|
104
|
+
raise Spurline::ConfigurationError,
|
|
105
|
+
"Unknown embedding model: #{model.inspect}."
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def persist_to_long_term!(turn)
|
|
110
|
+
text_parts = []
|
|
111
|
+
text_parts << extract_text(turn.input) if turn.input
|
|
112
|
+
text_parts << extract_text(turn.output) if turn.output
|
|
113
|
+
content_text = text_parts.join("\n")
|
|
114
|
+
return if content_text.strip.empty?
|
|
115
|
+
|
|
116
|
+
long_term.store(content: content_text, metadata: { turn_number: turn.number })
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_episodic_store(config)
|
|
120
|
+
enabled = case config
|
|
121
|
+
when nil
|
|
122
|
+
true
|
|
123
|
+
when true, false
|
|
124
|
+
config
|
|
125
|
+
when Hash
|
|
126
|
+
config.fetch(:enabled, true)
|
|
127
|
+
else
|
|
128
|
+
!!config
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
episodes = config.is_a?(Hash) ? config.fetch(:episodes, []) : []
|
|
132
|
+
EpisodicStore.new(enabled: enabled, episodes: episodes)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def extract_text(value)
|
|
136
|
+
case value
|
|
137
|
+
when Security::Content
|
|
138
|
+
value.text
|
|
139
|
+
when String
|
|
140
|
+
value
|
|
141
|
+
else
|
|
142
|
+
value.to_s
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Memory
|
|
5
|
+
# Sliding window of recent turns. Holds Turn objects with their Content
|
|
6
|
+
# (input/output) carrying inherited trust levels.
|
|
7
|
+
#
|
|
8
|
+
# When the window overflows, oldest turns are evicted. The last evicted
|
|
9
|
+
# turn is available via #last_evicted for potential summarization.
|
|
10
|
+
class ShortTerm
|
|
11
|
+
DEFAULT_WINDOW = 20
|
|
12
|
+
|
|
13
|
+
attr_reader :window_size, :last_evicted
|
|
14
|
+
|
|
15
|
+
def initialize(window: DEFAULT_WINDOW)
|
|
16
|
+
@window_size = window
|
|
17
|
+
@turns = []
|
|
18
|
+
@last_evicted = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_turn(turn)
|
|
22
|
+
@turns << turn
|
|
23
|
+
trim!
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns recent turns as an array, most recent last.
|
|
27
|
+
def recent(n = nil)
|
|
28
|
+
n ? @turns.last(n) : @turns.dup
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def size
|
|
32
|
+
@turns.length
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def full?
|
|
36
|
+
@turns.length >= @window_size
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def empty?
|
|
40
|
+
@turns.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def clear!
|
|
44
|
+
@turns.clear
|
|
45
|
+
@last_evicted = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def trim!
|
|
51
|
+
while @turns.length > window_size
|
|
52
|
+
@last_evicted = @turns.shift
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Orchestration
|
|
5
|
+
# Creates and runs child agents with permission-safe delegation.
|
|
6
|
+
#
|
|
7
|
+
# The setuid rule: child permissions are always <= parent permissions.
|
|
8
|
+
# Child scope inherits from the parent unless explicitly narrowed.
|
|
9
|
+
class AgentSpawner
|
|
10
|
+
def initialize(parent_agent:)
|
|
11
|
+
@parent_agent = parent_agent
|
|
12
|
+
@parent_session = parent_agent.session
|
|
13
|
+
@parent_scope = extract_scope(parent_agent)
|
|
14
|
+
@parent_permissions = extract_permissions(parent_agent)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# ASYNC-READY: spawns and runs a child agent, which is a blocking operation
|
|
18
|
+
def spawn(agent_class, input:, permissions: nil, scope: nil, &block)
|
|
19
|
+
validate_agent_class!(agent_class)
|
|
20
|
+
|
|
21
|
+
effective_permissions = compute_effective_permissions(permissions)
|
|
22
|
+
effective_scope = compute_effective_scope(scope)
|
|
23
|
+
|
|
24
|
+
child_agent = build_child_agent(
|
|
25
|
+
agent_class: agent_class,
|
|
26
|
+
permissions: effective_permissions,
|
|
27
|
+
scope: effective_scope
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
child_agent.session.metadata[:parent_session_id] = @parent_session.id
|
|
31
|
+
child_agent.session.metadata[:parent_agent_class] = @parent_agent.class.name
|
|
32
|
+
|
|
33
|
+
fire_parent_hook(:on_child_spawn, child_agent, agent_class)
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
child_agent.run(input) do |chunk|
|
|
37
|
+
block&.call(chunk)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
fire_parent_hook(:on_child_complete, child_agent, child_agent.session)
|
|
41
|
+
rescue Spurline::AgentError => e
|
|
42
|
+
fire_parent_hook(:on_child_error, child_agent, e)
|
|
43
|
+
raise Spurline::SpawnError,
|
|
44
|
+
"Child agent #{agent_class.name || agent_class} failed: #{e.message}. " \
|
|
45
|
+
"Parent session: #{@parent_session.id}, child session: #{child_agent.session.id}."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
child_agent.session
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_agent_class!(agent_class)
|
|
54
|
+
unless agent_class.is_a?(Class) && agent_class <= Spurline::Agent
|
|
55
|
+
raise Spurline::ConfigurationError,
|
|
56
|
+
"spawn_agent requires a class that inherits from Spurline::Agent. " \
|
|
57
|
+
"Got: #{agent_class.inspect}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compute_effective_permissions(child_permissions)
|
|
62
|
+
return deep_copy(@parent_permissions) if child_permissions.nil?
|
|
63
|
+
|
|
64
|
+
PermissionIntersection.validate_no_escalation!(
|
|
65
|
+
@parent_permissions,
|
|
66
|
+
child_permissions
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
PermissionIntersection.compute(
|
|
70
|
+
@parent_permissions,
|
|
71
|
+
child_permissions
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_effective_scope(child_scope)
|
|
76
|
+
return @parent_scope if child_scope.nil?
|
|
77
|
+
return child_scope if @parent_scope.nil?
|
|
78
|
+
|
|
79
|
+
if child_scope.is_a?(Spurline::Tools::Scope)
|
|
80
|
+
validate_scope_subset!(child_scope) if @parent_scope
|
|
81
|
+
child_scope
|
|
82
|
+
elsif child_scope.is_a?(Hash)
|
|
83
|
+
@parent_scope.narrow(child_scope)
|
|
84
|
+
else
|
|
85
|
+
raise Spurline::ConfigurationError,
|
|
86
|
+
"scope must be a Spurline::Tools::Scope or a Hash of constraints. " \
|
|
87
|
+
"Got: #{child_scope.class} (#{child_scope.inspect})"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_scope_subset!(child_scope)
|
|
92
|
+
return if child_scope.subset_of?(@parent_scope)
|
|
93
|
+
|
|
94
|
+
raise Spurline::ScopeViolationError,
|
|
95
|
+
"Child scope '#{child_scope.id}' is wider than parent scope '#{@parent_scope.id}'. " \
|
|
96
|
+
"A spawned agent cannot access resources outside the parent's scope. " \
|
|
97
|
+
"Narrow the child scope or widen the parent scope."
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_child_agent(agent_class:, permissions:, scope:)
|
|
101
|
+
child_agent = agent_class.new(
|
|
102
|
+
user: @parent_session.user,
|
|
103
|
+
scope: scope
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
inject_effective_permissions!(child_agent, permissions)
|
|
107
|
+
child_agent
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inject_effective_permissions!(child_agent, permissions)
|
|
111
|
+
return if permissions.nil?
|
|
112
|
+
|
|
113
|
+
tool_runner = child_agent.instance_variable_get(:@tool_runner)
|
|
114
|
+
return unless tool_runner
|
|
115
|
+
|
|
116
|
+
existing_permissions = tool_runner.instance_variable_get(:@permissions) || {}
|
|
117
|
+
merged_permissions = existing_permissions.merge(permissions)
|
|
118
|
+
tool_runner.instance_variable_set(:@permissions, merged_permissions)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def extract_scope(agent)
|
|
122
|
+
agent.instance_variable_get(:@scope)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def extract_permissions(agent)
|
|
126
|
+
klass = agent.class
|
|
127
|
+
return {} unless klass.respond_to?(:permissions_config)
|
|
128
|
+
|
|
129
|
+
klass.permissions_config
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fire_parent_hook(hook_type, *args)
|
|
133
|
+
hooks = @parent_agent.class.hooks_config[hook_type] || []
|
|
134
|
+
hooks.each { |hook_block| hook_block.call(*args) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def deep_copy(value)
|
|
138
|
+
case value
|
|
139
|
+
when Hash
|
|
140
|
+
value.each_with_object({}) do |(key, item), copy|
|
|
141
|
+
copy[key] = deep_copy(item)
|
|
142
|
+
end
|
|
143
|
+
when Array
|
|
144
|
+
value.map { |item| deep_copy(item) }
|
|
145
|
+
else
|
|
146
|
+
value
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Orchestration
|
|
5
|
+
# Stateless evaluator that decides whether worker output satisfies a task.
|
|
6
|
+
class Judge
|
|
7
|
+
STRATEGIES = %i[structured llm_eval custom].freeze
|
|
8
|
+
|
|
9
|
+
Verdict = Struct.new(:decision, :reason, :feedback, keyword_init: true) do
|
|
10
|
+
def accepted?
|
|
11
|
+
decision == :accept
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def rejected?
|
|
15
|
+
decision == :reject
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def needs_revision?
|
|
19
|
+
decision == :revise
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(strategy: :structured)
|
|
24
|
+
@strategy = strategy.to_sym
|
|
25
|
+
validate_strategy!(@strategy)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# ASYNC-READY: evaluate may call an LLM for :llm_eval strategy.
|
|
29
|
+
def evaluate(envelope:, output:, scheduler: Adapters::Scheduler::Sync.new, &custom_evaluator)
|
|
30
|
+
scheduler.run do
|
|
31
|
+
case @strategy
|
|
32
|
+
when :structured
|
|
33
|
+
evaluate_structured(envelope, output)
|
|
34
|
+
when :llm_eval
|
|
35
|
+
Verdict.new(
|
|
36
|
+
decision: :accept,
|
|
37
|
+
reason: "LLM evaluator stub",
|
|
38
|
+
feedback: "llm_eval strategy is a placeholder in M2.4"
|
|
39
|
+
)
|
|
40
|
+
when :custom
|
|
41
|
+
evaluate_custom(envelope, output, &custom_evaluator)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def evaluate_structured(envelope, output)
|
|
49
|
+
output_text = normalize_output(output)
|
|
50
|
+
criteria = envelope.acceptance_criteria.map(&:to_s)
|
|
51
|
+
|
|
52
|
+
missing = criteria.reject do |criterion|
|
|
53
|
+
output_text.downcase.include?(criterion.downcase)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if missing.empty?
|
|
57
|
+
Verdict.new(decision: :accept, reason: "All acceptance criteria matched", feedback: nil)
|
|
58
|
+
else
|
|
59
|
+
Verdict.new(
|
|
60
|
+
decision: :reject,
|
|
61
|
+
reason: "Missing acceptance criteria",
|
|
62
|
+
feedback: "Missing: #{missing.join(", ")}"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def evaluate_custom(envelope, output, &block)
|
|
68
|
+
raise ArgumentError, "custom evaluator block is required" unless block
|
|
69
|
+
|
|
70
|
+
result = block.call(envelope, output)
|
|
71
|
+
|
|
72
|
+
case result
|
|
73
|
+
when Verdict
|
|
74
|
+
result
|
|
75
|
+
when true
|
|
76
|
+
Verdict.new(decision: :accept, reason: "custom evaluator accepted", feedback: nil)
|
|
77
|
+
when false
|
|
78
|
+
Verdict.new(decision: :reject, reason: "custom evaluator rejected", feedback: nil)
|
|
79
|
+
when Hash
|
|
80
|
+
decision = (result[:decision] || result["decision"] || :reject).to_sym
|
|
81
|
+
reason = result[:reason] || result["reason"] || "custom evaluator result"
|
|
82
|
+
feedback = result[:feedback] || result["feedback"]
|
|
83
|
+
Verdict.new(decision: decision, reason: reason, feedback: feedback)
|
|
84
|
+
else
|
|
85
|
+
raise ArgumentError, "custom evaluator must return Verdict, boolean, or hash"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_strategy!(strategy)
|
|
90
|
+
return if STRATEGIES.include?(strategy)
|
|
91
|
+
|
|
92
|
+
raise Spurline::ConfigurationError, "invalid judge strategy: #{strategy.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def normalize_output(output)
|
|
96
|
+
case output
|
|
97
|
+
when String
|
|
98
|
+
output
|
|
99
|
+
when Hash
|
|
100
|
+
output.map { |key, value| "#{key}: #{value}" }.join("\n")
|
|
101
|
+
when Array
|
|
102
|
+
output.join("\n")
|
|
103
|
+
else
|
|
104
|
+
output.to_s
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Orchestration
|
|
5
|
+
class Ledger
|
|
6
|
+
module Store
|
|
7
|
+
# Abstract interface for ledger storage adapters.
|
|
8
|
+
class Base
|
|
9
|
+
def save_ledger(_ledger)
|
|
10
|
+
raise NotImplementedError, "#{self.class.name} must implement #save_ledger"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def load_ledger(_id)
|
|
14
|
+
raise NotImplementedError, "#{self.class.name} must implement #load_ledger"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def exists?(_id)
|
|
18
|
+
raise NotImplementedError, "#{self.class.name} must implement #exists?"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def delete(_id)
|
|
22
|
+
raise NotImplementedError, "#{self.class.name} must implement #delete"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Orchestration
|
|
5
|
+
class Ledger
|
|
6
|
+
module Store
|
|
7
|
+
# In-memory ledger store for tests and local development.
|
|
8
|
+
class Memory < Base
|
|
9
|
+
def initialize
|
|
10
|
+
@ledgers = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def save_ledger(ledger)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
@ledgers[ledger.id] = ledger.to_h
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def load_ledger(id)
|
|
21
|
+
payload = @mutex.synchronize { @ledgers[id.to_s] }
|
|
22
|
+
raise Spurline::LedgerError, "ledger not found: #{id}" if payload.nil?
|
|
23
|
+
|
|
24
|
+
Spurline::Orchestration::Ledger.from_h(payload, store: self)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def exists?(id)
|
|
28
|
+
@mutex.synchronize { @ledgers.key?(id.to_s) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def delete(id)
|
|
32
|
+
@mutex.synchronize { @ledgers.delete(id.to_s) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def size
|
|
36
|
+
@mutex.synchronize { @ledgers.size }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def clear!
|
|
40
|
+
@mutex.synchronize { @ledgers.clear }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def ids
|
|
44
|
+
@mutex.synchronize { @ledgers.keys.dup }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|