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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ # Registry of available LLM adapters. Maps symbolic names to adapter classes.
6
+ class Registry
7
+ def initialize
8
+ @adapters = {}
9
+ end
10
+
11
+ def register(name, adapter_class)
12
+ @adapters[name.to_sym] = adapter_class
13
+ end
14
+
15
+ def resolve(name)
16
+ name = name.to_sym
17
+ @adapters.fetch(name) do
18
+ raise Spurline::AdapterNotFoundError,
19
+ "Adapter '#{name}' is not registered. Available adapters: " \
20
+ "#{@adapters.keys.map(&:inspect).join(", ")}."
21
+ end
22
+ end
23
+
24
+ def registered?(name)
25
+ @adapters.key?(name.to_sym)
26
+ end
27
+
28
+ def names
29
+ @adapters.keys
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ module Scheduler
6
+ # Abstract scheduler interface. The scheduler parameter is the async seam (ADR-002).
7
+ # v1 ships only Sync. A future async scheduler will implement the same interface.
8
+ class Base
9
+ def run(&block)
10
+ raise NotImplementedError, "#{self.class.name} must implement #run"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ module Scheduler
6
+ # Synchronous no-op scheduler. Simply yields the block.
7
+ # This is the v1 default — the async seam (ADR-002).
8
+ class Sync < Base
9
+ def run(&block)
10
+ yield
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ # Test adapter that plays back canned streaming responses.
6
+ # Ships with the framework — available in production code for testing and demos.
7
+ #
8
+ # Usage:
9
+ # adapter = StubAdapter.new(responses: [
10
+ # stub_text("Here is what I found..."),
11
+ # stub_tool_call(:web_search, query: "test"),
12
+ # stub_text("Based on my research...")
13
+ # ])
14
+ class StubAdapter < Base
15
+ attr_reader :calls
16
+
17
+ def initialize(responses: [])
18
+ @responses = responses
19
+ @response_index = 0
20
+ @calls = []
21
+ end
22
+
23
+ # ASYNC-READY: scheduler param is the async entry point
24
+ def stream(messages:, system: nil, tools: [], config: {}, scheduler: Scheduler::Sync.new, &chunk_handler)
25
+ @calls << { messages: messages, system: system, tools: tools, config: config }
26
+
27
+ response = next_response!
28
+
29
+ response[:chunks].each do |chunk|
30
+ chunk_handler.call(chunk)
31
+ end
32
+
33
+ response
34
+ end
35
+
36
+ def call_count
37
+ @calls.length
38
+ end
39
+
40
+ private
41
+
42
+ def next_response!
43
+ if @response_index >= @responses.length
44
+ raise "StubAdapter exhausted: #{@responses.length} responses configured, " \
45
+ "but call ##{@response_index + 1} was made."
46
+ end
47
+
48
+ response = @responses[@response_index]
49
+ @response_index += 1
50
+ response
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lifecycle/suspension_boundary"
4
+
5
+ module Spurline
6
+ # The public API for Spurline agents. Developers inherit from this class.
7
+ #
8
+ # class ResearchAgent < Spurline::Agent
9
+ # use_model :claude_sonnet
10
+ # persona(:default) { system_prompt "You are a research assistant." }
11
+ # tools :web_search
12
+ # guardrails { max_tool_calls 5 }
13
+ # end
14
+ #
15
+ # agent = ResearchAgent.new
16
+ # agent.run("Research competitors") { |chunk| print chunk.text }
17
+ #
18
+ class Agent < Base
19
+ attr_reader :session, :state, :audit_log, :vault
20
+
21
+ def initialize(user: nil, session_id: nil, persona: :default, scope: nil, **opts)
22
+ @user = user
23
+ @session = Session::Session.load_or_create(
24
+ id: session_id,
25
+ store: self.class.session_store,
26
+ agent_class: self.class.name,
27
+ user: user
28
+ )
29
+ @scope = scope
30
+ @idempotency_ledger = @session.metadata[:idempotency_ledger] ||= {}
31
+ @persona = resolve_persona(persona)
32
+ @memory = Memory::Manager.new(config: self.class.memory_config)
33
+ @vault = Secrets::Vault.new
34
+
35
+ secret_resolver = Secrets::Resolver.new(
36
+ vault: @vault,
37
+ overrides: resolve_secret_overrides
38
+ )
39
+
40
+ @tool_runner = Tools::Runner.new(
41
+ registry: self.class.tool_registry,
42
+ guardrails: guardrail_settings,
43
+ permissions: self.class.respond_to?(:permissions_config) ? self.class.permissions_config : {},
44
+ secret_resolver: secret_resolver,
45
+ idempotency_configs: self.class.respond_to?(:idempotency_config) ? self.class.idempotency_config : {}
46
+ )
47
+ @pipeline = Security::ContextPipeline.new(guardrails: guardrail_settings)
48
+ @adapter = resolve_adapter
49
+ @audit_log = Audit::Log.new(
50
+ session: @session,
51
+ registry: self.class.tool_registry,
52
+ max_entries: resolve_audit_max_entries
53
+ )
54
+ @assembler = Memory::ContextAssembler.new
55
+ @state = @session.respond_to?(:suspended?) && @session.suspended? ? :suspended : :ready
56
+
57
+ # Restore memory from existing session if resuming
58
+ restore_session_memory!
59
+
60
+ run_hook(:on_start, @session)
61
+ end
62
+
63
+ # Single-shot execution. Streams chunks via block or returns an Enumerator (ADR-001).
64
+ def run(input, suspension_check: nil, mode: :normal, tool_sequence: nil, &block)
65
+ if block
66
+ execute_run(
67
+ input,
68
+ suspension_check: suspension_check,
69
+ mode: mode,
70
+ tool_sequence: tool_sequence,
71
+ &block
72
+ )
73
+ else
74
+ Streaming::StreamEnumerator.new do |consumer|
75
+ execute_run(
76
+ input,
77
+ suspension_check: suspension_check,
78
+ mode: mode,
79
+ tool_sequence: tool_sequence
80
+ ) { |chunk| consumer.call(chunk) }
81
+ end
82
+ end
83
+ end
84
+
85
+ # Multi-turn conversation. Session persists between calls.
86
+ # Resets agent state between turns to allow consecutive calls.
87
+ def chat(input, suspension_check: nil, mode: :normal, tool_sequence: nil, &block)
88
+ reset_for_next_turn! if @state == :complete
89
+
90
+ if block
91
+ execute_run(
92
+ input,
93
+ suspension_check: suspension_check,
94
+ mode: mode,
95
+ tool_sequence: tool_sequence,
96
+ &block
97
+ )
98
+ else
99
+ Streaming::StreamEnumerator.new do |consumer|
100
+ execute_run(
101
+ input,
102
+ suspension_check: suspension_check,
103
+ mode: mode,
104
+ tool_sequence: tool_sequence
105
+ ) { |chunk| consumer.call(chunk) }
106
+ end
107
+ end
108
+ end
109
+
110
+ # Resume a suspended session from its last checkpoint.
111
+ def resume(suspension_check: nil, &block)
112
+ unless @session.suspended?
113
+ raise Spurline::InvalidResumeError,
114
+ "Session is not suspended (state=#{@session.state.inspect})"
115
+ end
116
+
117
+ checkpoint = Session::Suspension.checkpoint_for(@session)
118
+ input = resume_input_from_checkpoint(checkpoint)
119
+
120
+ if block
121
+ execute_run(
122
+ input,
123
+ suspension_check: suspension_check,
124
+ resume_checkpoint: checkpoint,
125
+ &block
126
+ )
127
+ else
128
+ Streaming::StreamEnumerator.new do |consumer|
129
+ execute_run(
130
+ input,
131
+ suspension_check: suspension_check,
132
+ resume_checkpoint: checkpoint
133
+ ) { |chunk| consumer.call(chunk) }
134
+ end
135
+ end
136
+ end
137
+
138
+ # Test helper — swap the adapter for a stub.
139
+ def use_stub_adapter(responses: [])
140
+ @adapter = Adapters::StubAdapter.new(responses: responses)
141
+ end
142
+
143
+ # Spawn a child agent with inherited permissions and scope (ADR-005).
144
+ # The child runs in its own session and streams chunks to the provided block.
145
+ # Returns the child's session.
146
+ #
147
+ # @param agent_class [Class] A class that inherits from Spurline::Agent
148
+ # @param input [String] The input to pass to the child agent's #run
149
+ # @param permissions [Hash, nil] Optional permission overrides (must be <= parent)
150
+ # @param scope [Spurline::Tools::Scope, Hash, nil] Optional scope override (must be <= parent)
151
+ # @return [Spurline::Session::Session] The child agent's completed session
152
+ def spawn_agent(agent_class, input:, permissions: nil, scope: nil, &block)
153
+ spawner = Orchestration::AgentSpawner.new(parent_agent: self)
154
+ spawner.spawn(agent_class, input: input, permissions: permissions, scope: scope, &block)
155
+ end
156
+
157
+ # Structured per-session event trace.
158
+ def episodes
159
+ @memory.episodic
160
+ end
161
+
162
+ # Human-readable narrative of the episodic trace.
163
+ def explain
164
+ episodes.explain
165
+ end
166
+
167
+ private
168
+
169
+ def execute_run(
170
+ input,
171
+ suspension_check: nil,
172
+ resume_checkpoint: nil,
173
+ mode: :normal,
174
+ tool_sequence: nil,
175
+ &chunk_handler
176
+ )
177
+ if @session.suspended? && resume_checkpoint.nil?
178
+ raise Spurline::InvalidResumeError,
179
+ "Session is suspended. Use #resume to continue from checkpoint."
180
+ end
181
+
182
+ if mode == :deterministic
183
+ return execute_deterministic_run(input, tool_sequence: tool_sequence, &chunk_handler)
184
+ end
185
+
186
+ wrapped_input = resume_checkpoint ? input : wrap_input(input)
187
+ @state = :running
188
+ if resume_checkpoint
189
+ @session.resume!
190
+ run_hook(:on_resume, @session, resume_checkpoint)
191
+ else
192
+ @session.transition_to!(:running)
193
+ run_hook(:on_turn_start, @session)
194
+ end
195
+
196
+ runner = Lifecycle::Runner.new(
197
+ adapter: @adapter,
198
+ pipeline: @pipeline,
199
+ tool_runner: @tool_runner,
200
+ memory: @memory,
201
+ assembler: @assembler,
202
+ audit: @audit_log,
203
+ guardrails: guardrail_settings,
204
+ suspension_check: effective_suspension_check(suspension_check),
205
+ scope: @scope,
206
+ idempotency_ledger: @idempotency_ledger
207
+ )
208
+
209
+ runner.run(
210
+ input: wrapped_input,
211
+ session: @session,
212
+ persona: @persona,
213
+ tools_schema: build_tools_schema,
214
+ adapter_config: self.class.model_config || {},
215
+ agent_context: build_agent_context,
216
+ resume_checkpoint: resume_checkpoint
217
+ ) do |chunk|
218
+ run_hook(:on_tool_call, chunk.metadata, @session) if chunk.tool_end?
219
+ chunk_handler&.call(chunk)
220
+ end
221
+
222
+ @state = :complete
223
+ @session.complete!
224
+ run_hook(:on_turn_end, @session, @session.current_turn)
225
+ run_hook(:on_finish, @session)
226
+ rescue Spurline::Lifecycle::SuspensionSignal => e
227
+ @state = :suspended
228
+ @session.suspend!(checkpoint: e.checkpoint)
229
+ @audit_log.record(:suspended, turn: @session.current_turn&.number)
230
+ run_hook(:on_suspend, @session, e.checkpoint)
231
+ nil
232
+ rescue Spurline::InvalidResumeError
233
+ raise
234
+ rescue Spurline::AgentError => e
235
+ @state = :error
236
+ @session.error!(e)
237
+ @audit_log.record(:error, error: e.class.name, message: e.message)
238
+ run_hook(:on_error, e)
239
+ raise
240
+ end
241
+
242
+ def execute_deterministic_run(input, tool_sequence: nil, &chunk_handler)
243
+ sequence = tool_sequence || self.class.deterministic_sequence_config
244
+ if sequence.nil? || sequence.empty?
245
+ raise Spurline::ConfigurationError,
246
+ "No deterministic tool sequence configured. " \
247
+ "Declare one with `deterministic_sequence :tool1, :tool2` in the agent class, " \
248
+ "or pass `tool_sequence:` to #run."
249
+ end
250
+
251
+ wrapped_input = wrap_input(input)
252
+ @state = :running
253
+ @session.transition_to!(:running)
254
+ run_hook(:on_turn_start, @session)
255
+
256
+ det_runner = Lifecycle::DeterministicRunner.new(
257
+ tool_runner: @tool_runner,
258
+ audit_log: @audit_log,
259
+ session: @session,
260
+ guardrails: guardrail_settings,
261
+ scope: @scope,
262
+ idempotency_ledger: @idempotency_ledger
263
+ )
264
+
265
+ det_runner.run(
266
+ tool_sequence: sequence,
267
+ input: wrapped_input,
268
+ session: @session
269
+ ) do |chunk|
270
+ run_hook(:on_tool_call, chunk.metadata, @session) if chunk.tool_end?
271
+ chunk_handler&.call(chunk)
272
+ end
273
+
274
+ @memory.add_turn(@session.current_turn) if @session.current_turn
275
+ @state = :complete
276
+ @session.complete!
277
+ run_hook(:on_turn_end, @session, @session.current_turn)
278
+ run_hook(:on_finish, @session)
279
+ rescue Spurline::AgentError => e
280
+ @state = :error
281
+ @session.error!(e)
282
+ @audit_log.record(:error, error: e.class.name, message: e.message)
283
+ run_hook(:on_error, e)
284
+ raise
285
+ end
286
+
287
+ def wrap_input(input)
288
+ if input.is_a?(Security::Content)
289
+ input
290
+ else
291
+ Security::Gates::UserInput.wrap(input.to_s, user_id: @user.to_s)
292
+ end
293
+ end
294
+
295
+ def resolve_persona(name)
296
+ configs = self.class.persona_configs
297
+ config = configs[name.to_sym]
298
+ return nil unless config
299
+
300
+ Persona::Base.new(
301
+ name: name,
302
+ system_prompt: config.system_prompt_text,
303
+ injection_config: {
304
+ inject_date: config.date_injected?,
305
+ inject_user_context: config.user_context_injected?,
306
+ inject_agent_context: config.agent_context_injected?,
307
+ }
308
+ )
309
+ end
310
+
311
+ def resolve_adapter
312
+ config = self.class.model_config
313
+ return nil unless config
314
+
315
+ begin
316
+ adapter_class = self.class.adapter_registry.resolve(config[:name])
317
+ return adapter_class unless adapter_class.is_a?(Class)
318
+
319
+ # Build adapter kwargs from DEFAULT_ADAPTERS defaults + use_model kwargs.
320
+ # use_model kwargs (host:, port:, model:, options:, etc.) take precedence.
321
+ adapter_kwargs = {}
322
+ defaults = Base::DEFAULT_ADAPTERS[config[:name]]
323
+ adapter_kwargs[:model] = defaults[:model] if defaults && defaults[:model]
324
+
325
+ # Forward all use_model kwargs except :name (which is the adapter selector)
326
+ # and per-call API params like :tool_choice that belong in stream config, not constructor.
327
+ user_kwargs = config.reject { |k, _| %i[name tool_choice].include?(k) }
328
+ adapter_kwargs.merge!(user_kwargs)
329
+
330
+ adapter_kwargs.empty? ? adapter_class.new : adapter_class.new(**adapter_kwargs)
331
+ rescue Spurline::AdapterNotFoundError
332
+ # Adapter not yet registered — allows use_stub_adapter to set it later.
333
+ nil
334
+ end
335
+ end
336
+
337
+ def guardrail_settings
338
+ gc = self.class.guardrail_config
339
+ gc.respond_to?(:to_h) ? gc.to_h : gc.settings
340
+ end
341
+
342
+ def resolve_audit_max_entries
343
+ settings = guardrail_settings
344
+ guardrail_limit = settings[:audit_max_entries]
345
+ return guardrail_limit unless guardrail_limit.nil?
346
+
347
+ Spurline.config.audit_max_entries
348
+ end
349
+
350
+ def build_tools_schema
351
+ tool_config = self.class.tool_config
352
+ return [] unless tool_config
353
+
354
+ tool_config[:names].map do |name|
355
+ tool_class = self.class.tool_registry.fetch(name)
356
+ tool = tool_class.is_a?(Class) ? tool_class.new : tool_class
357
+ tool.to_schema
358
+ end
359
+ end
360
+
361
+ def build_agent_context
362
+ tool_config = self.class.tool_config
363
+ tool_names = tool_config ? tool_config[:names] : []
364
+
365
+ {
366
+ class_name: self.class.name || self.class.to_s,
367
+ tool_names: tool_names.map(&:to_s),
368
+ }
369
+ end
370
+
371
+ def run_hook(hook_type, *args)
372
+ hooks = self.class.hooks_config[hook_type] || []
373
+ hooks.each { |block| block.call(*args) }
374
+ end
375
+
376
+ def effective_suspension_check(suspension_check)
377
+ return suspension_check if suspension_check
378
+ return self.class.build_suspension_check if self.class.respond_to?(:build_suspension_check)
379
+
380
+ Lifecycle::SuspensionCheck.none
381
+ end
382
+
383
+ def resolve_secret_overrides
384
+ overrides = {}
385
+ tool_config = self.class.tool_config
386
+ return overrides unless tool_config
387
+
388
+ tool_config[:configs].each do |_tool_name, config|
389
+ next unless config.is_a?(Hash)
390
+
391
+ secrets = config[:secrets] || config["secrets"]
392
+ next unless secrets.is_a?(Hash)
393
+
394
+ secrets.each do |key, value|
395
+ overrides[key.to_sym] = value
396
+ end
397
+ end
398
+
399
+ overrides
400
+ end
401
+
402
+ def restore_session_memory!
403
+ @memory.restore_episodes(@session.metadata[:episodes] || [])
404
+ return unless @session.turns.any?(&:complete?)
405
+
406
+ resumption = Session::Resumption.new(session: @session, memory: @memory)
407
+ resumption.restore!
408
+ end
409
+
410
+ def resume_input_from_checkpoint(checkpoint)
411
+ serialized = checkpoint_value(checkpoint, :last_tool_result)
412
+ if serialized && !serialized.to_s.empty?
413
+ Security::Gates::ToolResult.wrap(serialized.to_s, tool_name: "suspended_resume")
414
+ elsif @session.current_turn
415
+ @session.current_turn.input
416
+ else
417
+ Security::Gates::UserInput.wrap("", user_id: @user.to_s)
418
+ end
419
+ end
420
+
421
+ def checkpoint_value(checkpoint, key)
422
+ return nil unless checkpoint
423
+
424
+ checkpoint[key] || checkpoint[key.to_s]
425
+ end
426
+
427
+ def reset_for_next_turn!
428
+ @state = :ready
429
+ # Session state stays as-is — load_or_create handles resumption.
430
+ # We just need to allow the agent to run again.
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Audit
5
+ # Records structured events for a session. All entries are flat records (ADR-003).
6
+ #
7
+ # Event types:
8
+ # :turn_start — A new turn begins
9
+ # :turn_end — A turn completes
10
+ # :llm_request — Outbound LLM request shape
11
+ # :llm_response — Inbound LLM response shape
12
+ # :tool_call — A tool was invoked
13
+ # :tool_result — A tool returned a result
14
+ # :error — An error occurred
15
+ # :injection_blocked — Injection attempt detected and blocked
16
+ # :pii_detected — PII was detected (mode-dependent behavior)
17
+ # :max_tool_calls_reached — Tool call limit was hit
18
+ # :session_complete — Session finished
19
+ # :session_error — Session errored
20
+ class Log
21
+ KNOWN_EVENTS = %i[
22
+ turn_start turn_end
23
+ llm_request llm_response
24
+ tool_call tool_result
25
+ error
26
+ injection_blocked pii_detected
27
+ max_tool_calls_reached
28
+ session_complete session_error
29
+ ].freeze
30
+
31
+ attr_reader :entries, :evicted_count
32
+
33
+ def initialize(session:, registry: nil, max_entries: nil)
34
+ @session = session
35
+ @registry = registry
36
+ @max_entries = max_entries
37
+ @entries = []
38
+ @evicted_count = 0
39
+ @started_at = Time.now
40
+ end
41
+
42
+ def record(event_type, data = {})
43
+ event = event_type.to_sym
44
+ record_data = maybe_filter_tool_arguments(event, data)
45
+
46
+ entry = {
47
+ event: event,
48
+ timestamp: Time.now,
49
+ session_id: @session.id,
50
+ elapsed_ms: elapsed_ms,
51
+ **record_data,
52
+ }
53
+ @entries << entry
54
+ evict_if_needed!
55
+ entry
56
+ end
57
+
58
+ def size
59
+ @entries.length
60
+ end
61
+
62
+ # Filter entries by event type.
63
+ def events_of_type(event_type)
64
+ @entries.select { |e| e[:event] == event_type.to_sym }
65
+ end
66
+
67
+ # All tool call entries.
68
+ def tool_calls
69
+ events_of_type(:tool_call)
70
+ end
71
+
72
+ # All error entries.
73
+ def errors
74
+ events_of_type(:error)
75
+ end
76
+
77
+ # All llm request entries.
78
+ def llm_requests
79
+ events_of_type(:llm_request)
80
+ end
81
+
82
+ # All llm response entries.
83
+ def llm_responses
84
+ events_of_type(:llm_response)
85
+ end
86
+
87
+ # All entries for a specific turn.
88
+ def turn_events(turn_number)
89
+ @entries.select { |e| e[:turn] == turn_number }
90
+ end
91
+
92
+ # Compact event stream suitable for replay/debugging.
93
+ def replay_timeline
94
+ @entries.map do |entry|
95
+ {
96
+ event: entry[:event],
97
+ elapsed_ms: entry[:elapsed_ms],
98
+ turn: entry[:turn],
99
+ loop: entry[:loop],
100
+ tool: entry[:tool],
101
+ }.compact
102
+ end
103
+ end
104
+
105
+ # Total duration of all recorded tool calls.
106
+ def total_tool_duration_ms
107
+ tool_calls.sum { |tc| tc[:duration_ms] || 0 }
108
+ end
109
+
110
+ # Compact summary of the audit log.
111
+ def summary
112
+ {
113
+ session_id: @session.id,
114
+ total_events: size + @evicted_count,
115
+ evicted_entries: @evicted_count,
116
+ turns: events_of_type(:turn_start).length,
117
+ tool_calls: tool_calls.length,
118
+ errors: errors.length,
119
+ total_tool_duration_ms: total_tool_duration_ms,
120
+ total_elapsed_ms: elapsed_ms,
121
+ }
122
+ end
123
+
124
+ private
125
+
126
+ def elapsed_ms
127
+ ((Time.now - @started_at) * 1000).round
128
+ end
129
+
130
+ def maybe_filter_tool_arguments(event_type, data)
131
+ return data unless event_type == :tool_call
132
+ return data unless data.is_a?(Hash)
133
+ return data unless data.key?(:arguments) || data.key?("arguments")
134
+
135
+ tool_name = data[:tool] || data["tool"]
136
+ arguments_key = data.key?(:arguments) ? :arguments : "arguments"
137
+ filtered_arguments = SecretFilter.filter(
138
+ data[arguments_key],
139
+ tool_name: tool_name,
140
+ registry: @registry
141
+ )
142
+ data.merge(arguments_key => filtered_arguments)
143
+ end
144
+
145
+ def evict_if_needed!
146
+ return unless @max_entries
147
+ return unless @max_entries.is_a?(Integer) && @max_entries.positive?
148
+
149
+ while @entries.length > @max_entries
150
+ @entries.shift
151
+ @evicted_count += 1
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end