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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. 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