spurline-core 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/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -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/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +18 -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/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +12 -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/CLAUDE.md +12 -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 +333 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
# Base class for spur gems. Spur gems are standard Ruby gems that self-register
|
|
5
|
+
# tools and permissions into the Spurline framework on require.
|
|
6
|
+
#
|
|
7
|
+
# The spur contract is locked — this interface cannot change after ship.
|
|
8
|
+
#
|
|
9
|
+
# Usage in a spur gem (e.g., spurline-web):
|
|
10
|
+
#
|
|
11
|
+
# module SpurlineWeb
|
|
12
|
+
# class Railtie < Spurline::Spur
|
|
13
|
+
# spur_name "spurline-web"
|
|
14
|
+
#
|
|
15
|
+
# tools do
|
|
16
|
+
# register :web_search, SpurlineWeb::Tools::WebSearch
|
|
17
|
+
# register :scrape, SpurlineWeb::Tools::Scraper
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# permissions do
|
|
21
|
+
# default_trust :external
|
|
22
|
+
# requires_confirmation false
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
class Spur
|
|
28
|
+
class << self
|
|
29
|
+
# Track all registered spurs for introspection.
|
|
30
|
+
def registry
|
|
31
|
+
@registry ||= {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Tool registrations deferred because Agent wasn't loaded yet.
|
|
35
|
+
def pending_registrations
|
|
36
|
+
@pending_registrations ||= []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Adapter registrations deferred because Agent wasn't loaded yet.
|
|
40
|
+
def pending_adapter_registrations
|
|
41
|
+
@pending_adapter_registrations ||= []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Replay deferred tool registrations into the given tool registry.
|
|
45
|
+
def flush_pending_registrations!(registry)
|
|
46
|
+
return if pending_registrations.empty?
|
|
47
|
+
|
|
48
|
+
pending_registrations.each do |registration|
|
|
49
|
+
registry.register(registration[:name], registration[:tool_class])
|
|
50
|
+
end
|
|
51
|
+
pending_registrations.clear
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Replay deferred adapter registrations into the given adapter registry.
|
|
55
|
+
def flush_pending_adapter_registrations!(registry)
|
|
56
|
+
return if pending_adapter_registrations.empty?
|
|
57
|
+
|
|
58
|
+
pending_adapter_registrations.each do |registration|
|
|
59
|
+
registry.register(registration[:name], registration[:adapter_class])
|
|
60
|
+
end
|
|
61
|
+
pending_adapter_registrations.clear
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Called by subclasses to set the spur gem name.
|
|
65
|
+
def spur_name(name = nil)
|
|
66
|
+
if name
|
|
67
|
+
@spur_name = name
|
|
68
|
+
else
|
|
69
|
+
@spur_name || self.name
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# DSL block for registering tools.
|
|
74
|
+
def tools(&block)
|
|
75
|
+
@tool_registrations ||= []
|
|
76
|
+
if block
|
|
77
|
+
context = ToolRegistrationContext.new
|
|
78
|
+
context.instance_eval(&block)
|
|
79
|
+
@tool_registrations = context.registrations
|
|
80
|
+
end
|
|
81
|
+
@tool_registrations
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# DSL block for registering adapters.
|
|
85
|
+
def adapters(&block)
|
|
86
|
+
@adapter_registrations ||= []
|
|
87
|
+
if block
|
|
88
|
+
context = AdapterRegistrationContext.new
|
|
89
|
+
context.instance_eval(&block)
|
|
90
|
+
@adapter_registrations = context.registrations
|
|
91
|
+
end
|
|
92
|
+
@adapter_registrations
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# DSL block for declaring default permissions.
|
|
96
|
+
def permissions(&block)
|
|
97
|
+
@permission_defaults ||= {}
|
|
98
|
+
if block
|
|
99
|
+
context = PermissionContext.new
|
|
100
|
+
context.instance_eval(&block)
|
|
101
|
+
@permission_defaults = context.settings
|
|
102
|
+
end
|
|
103
|
+
@permission_defaults
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Hook called when a subclass is defined. Auto-registers the spur.
|
|
107
|
+
def inherited(subclass)
|
|
108
|
+
super
|
|
109
|
+
# Defer registration to allow the class body to execute first.
|
|
110
|
+
TracePoint.new(:end) do |tp|
|
|
111
|
+
if tp.self == subclass
|
|
112
|
+
tp.disable
|
|
113
|
+
subclass.send(:auto_register!)
|
|
114
|
+
end
|
|
115
|
+
end.enable
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# Auto-registers this spur's tools and adapters into the global
|
|
121
|
+
# Spurline::Agent registries. If Agent hasn't been loaded yet (Zeitwerk
|
|
122
|
+
# lazy loading), the registrations are deferred and replayed when the
|
|
123
|
+
# respective registry is first accessed.
|
|
124
|
+
def auto_register!
|
|
125
|
+
Spur.registry[spur_name] = {
|
|
126
|
+
tools: tools.map { |r| r[:name] },
|
|
127
|
+
adapters: adapters.map { |r| r[:name] },
|
|
128
|
+
permissions: permissions,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
register_tools!
|
|
132
|
+
register_adapters!
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def register_tools!
|
|
136
|
+
return if tools.empty?
|
|
137
|
+
|
|
138
|
+
if defined?(Spurline::Agent) && Spurline::Agent.respond_to?(:tool_registry)
|
|
139
|
+
tools.each do |registration|
|
|
140
|
+
Spurline::Agent.tool_registry.register(
|
|
141
|
+
registration[:name],
|
|
142
|
+
registration[:tool_class]
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
else
|
|
146
|
+
Spur.pending_registrations.concat(tools)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def register_adapters!
|
|
151
|
+
return if adapters.empty?
|
|
152
|
+
|
|
153
|
+
if defined?(Spurline::Agent) && Spurline::Agent.respond_to?(:adapter_registry)
|
|
154
|
+
adapters.each do |registration|
|
|
155
|
+
Spurline::Agent.adapter_registry.register(
|
|
156
|
+
registration[:name],
|
|
157
|
+
registration[:adapter_class]
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
else
|
|
161
|
+
Spur.pending_adapter_registrations.concat(adapters)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Context object for the `tools` DSL block.
|
|
167
|
+
class ToolRegistrationContext
|
|
168
|
+
attr_reader :registrations
|
|
169
|
+
|
|
170
|
+
def initialize
|
|
171
|
+
@registrations = []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def register(name, tool_class)
|
|
175
|
+
@registrations << { name: name.to_sym, tool_class: tool_class }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Context object for the `adapters` DSL block.
|
|
180
|
+
class AdapterRegistrationContext
|
|
181
|
+
attr_reader :registrations
|
|
182
|
+
|
|
183
|
+
def initialize
|
|
184
|
+
@registrations = []
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def register(name, adapter_class)
|
|
188
|
+
@registrations << { name: name.to_sym, adapter_class: adapter_class }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Context object for the `permissions` DSL block.
|
|
193
|
+
class PermissionContext
|
|
194
|
+
attr_reader :settings
|
|
195
|
+
|
|
196
|
+
def initialize
|
|
197
|
+
@settings = {}
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def default_trust(level)
|
|
201
|
+
@settings[:default_trust] = level
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def requires_confirmation(val = true)
|
|
205
|
+
@settings[:requires_confirmation] = val
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def sandbox(val = true)
|
|
209
|
+
@settings[:sandbox] = val
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3631 | 6:00 PM | ⚖️ | Long-term memory architecture with pgvector and OpenAI embeddings | ~501 |
|
|
11
|
+
| #3632 | " | ⚖️ | OpenAI adapter architecture with stop reason normalization and tool call accumulation | ~541 |
|
|
12
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# Accumulates streaming chunks to detect tool call boundaries.
|
|
6
|
+
# Text chunks are yielded immediately to the caller.
|
|
7
|
+
# Tool calls are only dispatched when the full argument payload has arrived.
|
|
8
|
+
#
|
|
9
|
+
# Handles edge cases:
|
|
10
|
+
# - Multiple tool calls in a single response
|
|
11
|
+
# - Partial JSON arguments across chunks
|
|
12
|
+
# - Mixed text and tool call responses
|
|
13
|
+
class Buffer
|
|
14
|
+
attr_reader :chunks
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@chunks = []
|
|
18
|
+
@stop_reason = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def <<(chunk)
|
|
22
|
+
@chunks << chunk
|
|
23
|
+
@stop_reason = chunk.metadata[:stop_reason] if chunk.done?
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def complete?
|
|
28
|
+
@chunks.any?(&:done?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tool_call?
|
|
32
|
+
@stop_reason == "tool_use"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def text_chunks
|
|
36
|
+
@chunks.select(&:text?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def full_text
|
|
40
|
+
text_chunks.map(&:text).join
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns all tool call data from metadata.
|
|
44
|
+
# Supports multiple tool calls in a single response.
|
|
45
|
+
def tool_calls
|
|
46
|
+
calls = @chunks
|
|
47
|
+
.select { |c| c.metadata[:tool_call] }
|
|
48
|
+
.map { |c| c.metadata[:tool_call] }
|
|
49
|
+
|
|
50
|
+
# Deduplicate by name+arguments (guards against duplicate chunk delivery)
|
|
51
|
+
calls.uniq { |c| [c[:name], c[:arguments]] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tool_call_count
|
|
55
|
+
tool_calls.length
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The stop reason from the done chunk.
|
|
59
|
+
def stop_reason
|
|
60
|
+
@stop_reason
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear!
|
|
64
|
+
@chunks = []
|
|
65
|
+
@stop_reason = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def size
|
|
69
|
+
@chunks.length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty?
|
|
73
|
+
@chunks.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# A typed chunk of streaming output. Never a raw string.
|
|
6
|
+
#
|
|
7
|
+
# Types:
|
|
8
|
+
# :text — text content from the LLM
|
|
9
|
+
# :tool_start — a tool execution is beginning
|
|
10
|
+
# :tool_end — a tool execution has completed
|
|
11
|
+
# :done — the stream is complete
|
|
12
|
+
class Chunk
|
|
13
|
+
TYPES = %i[text tool_start tool_end done].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :type, :text, :turn, :session_id, :metadata
|
|
16
|
+
|
|
17
|
+
def initialize(type:, text: nil, turn: nil, session_id: nil, metadata: {})
|
|
18
|
+
validate_type!(type)
|
|
19
|
+
|
|
20
|
+
@type = type
|
|
21
|
+
@text = text&.dup&.freeze
|
|
22
|
+
@turn = turn
|
|
23
|
+
@session_id = session_id
|
|
24
|
+
@metadata = metadata.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def text?
|
|
29
|
+
type == :text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tool_start?
|
|
33
|
+
type == :tool_start
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_end?
|
|
37
|
+
type == :tool_end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def done?
|
|
41
|
+
type == :done
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
parts = ["type=#{type}"]
|
|
46
|
+
parts << "text=#{text[0..30].inspect}#{text.length > 30 ? "..." : ""}" if text
|
|
47
|
+
parts << "turn=#{turn}" if turn
|
|
48
|
+
"#<Spurline::Streaming::Chunk #{parts.join(" ")}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_type!(type)
|
|
54
|
+
return if TYPES.include?(type)
|
|
55
|
+
|
|
56
|
+
raise Spurline::ConfigurationError,
|
|
57
|
+
"Invalid chunk type: #{type.inspect}. " \
|
|
58
|
+
"Must be one of: #{TYPES.map(&:inspect).join(", ")}."
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# Wraps a block-based streaming interface into a Ruby Enumerator.
|
|
6
|
+
# This allows both block and enumerator usage patterns (ADR-001):
|
|
7
|
+
#
|
|
8
|
+
# agent.run("hello") { |chunk| print chunk.text }
|
|
9
|
+
# agent.run("hello").each { |chunk| print chunk.text }
|
|
10
|
+
#
|
|
11
|
+
class StreamEnumerator
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize(&producer)
|
|
15
|
+
@producer = producer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&consumer)
|
|
19
|
+
if consumer
|
|
20
|
+
@producer.call(consumer)
|
|
21
|
+
else
|
|
22
|
+
::Enumerator.new do |yielder|
|
|
23
|
+
@producer.call(proc { |chunk| yielder << chunk })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
# Test helpers for Spurline agents. Require in your spec_helper:
|
|
5
|
+
#
|
|
6
|
+
# require "spurline/testing"
|
|
7
|
+
#
|
|
8
|
+
# Then include in your specs:
|
|
9
|
+
#
|
|
10
|
+
# include Spurline::Testing
|
|
11
|
+
#
|
|
12
|
+
# Or let the auto-configuration handle it (included globally if RSpec is loaded).
|
|
13
|
+
module Testing
|
|
14
|
+
TOOL_SOURCE_PATTERN = /source=["']tool:(?<tool_name>[^"']+)["']/.freeze
|
|
15
|
+
|
|
16
|
+
# Creates a stub text response that streams as chunks.
|
|
17
|
+
def stub_text(text, turn: 1)
|
|
18
|
+
chunks = text.chars.each_slice(5).map do |chars|
|
|
19
|
+
Spurline::Streaming::Chunk.new(
|
|
20
|
+
type: :text,
|
|
21
|
+
text: chars.join,
|
|
22
|
+
turn: turn
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
chunks << Spurline::Streaming::Chunk.new(
|
|
27
|
+
type: :done,
|
|
28
|
+
turn: turn,
|
|
29
|
+
metadata: { stop_reason: "end_turn" }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
{ type: :text, text: text, chunks: chunks }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates a stub tool call response.
|
|
36
|
+
def stub_tool_call(tool_name, turn: 1, **arguments)
|
|
37
|
+
tool_call_data = { name: tool_name.to_s, arguments: arguments }
|
|
38
|
+
|
|
39
|
+
chunks = [
|
|
40
|
+
Spurline::Streaming::Chunk.new(
|
|
41
|
+
type: :tool_start,
|
|
42
|
+
turn: turn,
|
|
43
|
+
metadata: { tool_name: tool_name.to_s, arguments: arguments }
|
|
44
|
+
),
|
|
45
|
+
Spurline::Streaming::Chunk.new(
|
|
46
|
+
type: :done,
|
|
47
|
+
turn: turn,
|
|
48
|
+
metadata: {
|
|
49
|
+
stop_reason: "tool_use",
|
|
50
|
+
tool_call: tool_call_data,
|
|
51
|
+
}
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
{ type: :tool_call, tool_call: tool_call_data, chunks: chunks }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Asserts that a tool was called, optionally with matching arguments.
|
|
59
|
+
#
|
|
60
|
+
# Sources checked in order:
|
|
61
|
+
# 1) audit_log tool_call events
|
|
62
|
+
# 2) session turn tool_calls
|
|
63
|
+
# 3) StubAdapter call history (tool presence only)
|
|
64
|
+
#
|
|
65
|
+
# @param tool_name [Symbol, String]
|
|
66
|
+
# @param with [Hash] expected argument subset
|
|
67
|
+
# @param agent [Spurline::Agent, nil]
|
|
68
|
+
# @param adapter [Spurline::Adapters::StubAdapter, nil]
|
|
69
|
+
# @param audit_log [Spurline::Audit::Log, nil]
|
|
70
|
+
# @param session [Spurline::Session::Session, nil]
|
|
71
|
+
# @return [true]
|
|
72
|
+
def assert_tool_called(tool_name, with: {}, agent: nil, adapter: nil, audit_log: nil, session: nil)
|
|
73
|
+
tool = tool_name.to_s
|
|
74
|
+
expected_arguments = deep_symbolize(with || {})
|
|
75
|
+
audit_entries, session_entries, adapter_instance = resolve_tool_call_sources(
|
|
76
|
+
agent: agent,
|
|
77
|
+
adapter: adapter,
|
|
78
|
+
audit_log: audit_log,
|
|
79
|
+
session: session
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
matched = match_tool_call(tool, expected_arguments, audit_entries, key: :tool) ||
|
|
83
|
+
match_tool_call(tool, expected_arguments, session_entries, key: :name)
|
|
84
|
+
return true if matched
|
|
85
|
+
|
|
86
|
+
if tool_called_in_history?(tool, audit_entries, key: :tool) ||
|
|
87
|
+
tool_called_in_history?(tool, session_entries, key: :name)
|
|
88
|
+
raise_expectation!(
|
|
89
|
+
"Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if tool_called_in_adapter_history?(adapter_instance, tool)
|
|
94
|
+
if expected_arguments.empty?
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
raise_expectation!(
|
|
99
|
+
"Tool '#{tool}' was detected in StubAdapter call history, but argument assertions " \
|
|
100
|
+
"require session or audit history. Pass `agent:`, `session:`, or `audit_log:`."
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise_expectation!(
|
|
105
|
+
"Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Asserts that no injection detection error is raised while evaluating the block.
|
|
110
|
+
#
|
|
111
|
+
# @yield A call that runs through the context pipeline.
|
|
112
|
+
# @return [true]
|
|
113
|
+
def expect_no_injection
|
|
114
|
+
raise ArgumentError, "expect_no_injection requires a block" unless block_given?
|
|
115
|
+
|
|
116
|
+
yield
|
|
117
|
+
true
|
|
118
|
+
rescue *injection_error_classes => e
|
|
119
|
+
raise_expectation!(
|
|
120
|
+
"Expected no injection detection errors, but #{e.class.name} was raised: #{e.message}"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Asserts that a Content object carries the expected trust level.
|
|
125
|
+
#
|
|
126
|
+
# @param content [Spurline::Security::Content]
|
|
127
|
+
# @param expected_trust [Symbol, String]
|
|
128
|
+
# @return [true]
|
|
129
|
+
def assert_trust_level(content, expected_trust)
|
|
130
|
+
unless content.is_a?(Spurline::Security::Content)
|
|
131
|
+
raise_expectation!(
|
|
132
|
+
"Expected Spurline::Security::Content, got #{content.class.name}."
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
expected = expected_trust.to_sym
|
|
137
|
+
actual = content.trust
|
|
138
|
+
return true if actual == expected
|
|
139
|
+
|
|
140
|
+
raise_expectation!("Expected trust level #{expected.inspect}, got #{actual.inspect}.")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def resolve_tool_call_sources(agent:, adapter:, audit_log:, session:)
|
|
146
|
+
audit = audit_log
|
|
147
|
+
sess = session
|
|
148
|
+
adapter_instance = adapter
|
|
149
|
+
|
|
150
|
+
if agent
|
|
151
|
+
audit ||= agent.respond_to?(:audit_log) ? agent.audit_log : nil
|
|
152
|
+
sess ||= agent.respond_to?(:session) ? agent.session : nil
|
|
153
|
+
if adapter_instance.nil? && agent.instance_variable_defined?(:@adapter)
|
|
154
|
+
adapter_instance = agent.instance_variable_get(:@adapter)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
audit_entries = audit.respond_to?(:tool_calls) ? audit.tool_calls : []
|
|
159
|
+
session_entries = sess.respond_to?(:tool_calls) ? sess.tool_calls : []
|
|
160
|
+
|
|
161
|
+
[audit_entries, session_entries, adapter_instance]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def match_tool_call(tool, expected_arguments, entries, key:)
|
|
165
|
+
entries.find do |entry|
|
|
166
|
+
next false unless entry[key].to_s == tool
|
|
167
|
+
|
|
168
|
+
actual_arguments = deep_symbolize(entry[:arguments] || {})
|
|
169
|
+
hash_subset?(actual_arguments, expected_arguments)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def tool_called_in_history?(tool, entries, key:)
|
|
174
|
+
entries.any? { |entry| entry[key].to_s == tool }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def tool_called_in_adapter_history?(adapter, tool)
|
|
178
|
+
return false unless adapter.respond_to?(:calls)
|
|
179
|
+
|
|
180
|
+
adapter.calls.any? do |call|
|
|
181
|
+
messages = Array(call[:messages])
|
|
182
|
+
messages.any? do |message|
|
|
183
|
+
content = message[:content].to_s
|
|
184
|
+
match = TOOL_SOURCE_PATTERN.match(content)
|
|
185
|
+
match && match[:tool_name] == tool
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def hash_subset?(actual, expected)
|
|
191
|
+
return true if expected.empty?
|
|
192
|
+
return false unless actual.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
expected.all? do |key, value|
|
|
195
|
+
actual_value = actual[key]
|
|
196
|
+
if value.is_a?(Hash)
|
|
197
|
+
hash_subset?(actual_value, value)
|
|
198
|
+
else
|
|
199
|
+
actual_value == value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def deep_symbolize(value)
|
|
205
|
+
case value
|
|
206
|
+
when Hash
|
|
207
|
+
value.each_with_object({}) do |(key, nested), hash|
|
|
208
|
+
hash[key.to_sym] = deep_symbolize(nested)
|
|
209
|
+
end
|
|
210
|
+
when Array
|
|
211
|
+
value.map { |item| deep_symbolize(item) }
|
|
212
|
+
else
|
|
213
|
+
value
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def format_expected_arguments(arguments)
|
|
218
|
+
return "" if arguments.empty?
|
|
219
|
+
|
|
220
|
+
" with arguments #{arguments.inspect}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def injection_error_classes
|
|
224
|
+
classes = []
|
|
225
|
+
classes << Spurline::InjectionAttemptError if defined?(Spurline::InjectionAttemptError)
|
|
226
|
+
classes << Spurline::InjectionDetectedError if defined?(Spurline::InjectionDetectedError)
|
|
227
|
+
classes
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def raise_expectation!(message)
|
|
231
|
+
if defined?(RSpec::Expectations::ExpectationNotMetError)
|
|
232
|
+
raise RSpec::Expectations::ExpectationNotMetError, message
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
raise RuntimeError, message
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Auto-include in RSpec if available
|
|
241
|
+
if defined?(RSpec)
|
|
242
|
+
RSpec.configure do |config|
|
|
243
|
+
config.include Spurline::Testing
|
|
244
|
+
end
|
|
245
|
+
end
|