spurline-test 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/lib/spurline/adapters/base.rb +17 -0
- data/lib/spurline/adapters/claude.rb +208 -0
- data/lib/spurline/adapters/open_ai.rb +213 -0
- data/lib/spurline/adapters/registry.rb +33 -0
- data/lib/spurline/adapters/scheduler/base.rb +15 -0
- data/lib/spurline/adapters/scheduler/sync.rb +15 -0
- data/lib/spurline/adapters/stub_adapter.rb +54 -0
- data/lib/spurline/agent.rb +433 -0
- data/lib/spurline/audit/log.rb +156 -0
- data/lib/spurline/audit/secret_filter.rb +121 -0
- data/lib/spurline/base.rb +130 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
- data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
- data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
- data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
- data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
- data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
- data/lib/spurline/cartographer/repo_profile.rb +140 -0
- data/lib/spurline/cartographer/runner.rb +88 -0
- data/lib/spurline/cartographer.rb +6 -0
- data/lib/spurline/channels/base.rb +41 -0
- data/lib/spurline/channels/event.rb +136 -0
- data/lib/spurline/channels/github.rb +205 -0
- data/lib/spurline/channels/router.rb +103 -0
- data/lib/spurline/cli/check.rb +88 -0
- data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
- data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
- data/lib/spurline/cli/checks/base.rb +35 -0
- data/lib/spurline/cli/checks/credentials.rb +43 -0
- data/lib/spurline/cli/checks/permissions.rb +22 -0
- data/lib/spurline/cli/checks/project_structure.rb +48 -0
- data/lib/spurline/cli/checks/session_store.rb +97 -0
- data/lib/spurline/cli/console.rb +73 -0
- data/lib/spurline/cli/credentials.rb +181 -0
- data/lib/spurline/cli/generators/agent.rb +123 -0
- data/lib/spurline/cli/generators/migration.rb +62 -0
- data/lib/spurline/cli/generators/project.rb +331 -0
- data/lib/spurline/cli/generators/tool.rb +98 -0
- data/lib/spurline/cli/router.rb +121 -0
- data/lib/spurline/configuration.rb +23 -0
- data/lib/spurline/dsl/guardrails.rb +108 -0
- data/lib/spurline/dsl/hooks.rb +51 -0
- data/lib/spurline/dsl/memory.rb +39 -0
- data/lib/spurline/dsl/model.rb +23 -0
- data/lib/spurline/dsl/persona.rb +74 -0
- data/lib/spurline/dsl/suspend_until.rb +53 -0
- data/lib/spurline/dsl/tools.rb +176 -0
- data/lib/spurline/errors.rb +109 -0
- data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
- data/lib/spurline/lifecycle/runner.rb +456 -0
- data/lib/spurline/lifecycle/states.rb +47 -0
- data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/base.rb +17 -0
- data/lib/spurline/memory/embedder/open_ai.rb +70 -0
- data/lib/spurline/memory/episode.rb +56 -0
- data/lib/spurline/memory/episodic_store.rb +147 -0
- data/lib/spurline/memory/long_term/base.rb +22 -0
- data/lib/spurline/memory/long_term/postgres.rb +106 -0
- data/lib/spurline/memory/manager.rb +147 -0
- data/lib/spurline/memory/short_term.rb +57 -0
- data/lib/spurline/orchestration/agent_spawner.rb +151 -0
- data/lib/spurline/orchestration/judge.rb +109 -0
- data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
- data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
- data/lib/spurline/orchestration/ledger.rb +339 -0
- data/lib/spurline/orchestration/merge_queue.rb +133 -0
- data/lib/spurline/orchestration/permission_intersection.rb +151 -0
- data/lib/spurline/orchestration/task_envelope.rb +201 -0
- data/lib/spurline/persona/base.rb +42 -0
- data/lib/spurline/persona/registry.rb +42 -0
- data/lib/spurline/secrets/resolver.rb +65 -0
- data/lib/spurline/secrets/vault.rb +42 -0
- data/lib/spurline/security/content.rb +76 -0
- data/lib/spurline/security/context_pipeline.rb +58 -0
- data/lib/spurline/security/gates/base.rb +36 -0
- data/lib/spurline/security/gates/operator_config.rb +22 -0
- data/lib/spurline/security/gates/system_prompt.rb +23 -0
- data/lib/spurline/security/gates/tool_result.rb +23 -0
- data/lib/spurline/security/gates/user_input.rb +22 -0
- data/lib/spurline/security/injection_scanner.rb +109 -0
- data/lib/spurline/security/pii_filter.rb +104 -0
- data/lib/spurline/session/resumption.rb +36 -0
- data/lib/spurline/session/serializer.rb +169 -0
- data/lib/spurline/session/session.rb +154 -0
- data/lib/spurline/session/store/base.rb +27 -0
- data/lib/spurline/session/store/memory.rb +45 -0
- data/lib/spurline/session/store/postgres.rb +123 -0
- data/lib/spurline/session/store/sqlite.rb +139 -0
- data/lib/spurline/session/suspension.rb +93 -0
- data/lib/spurline/session/turn.rb +98 -0
- data/lib/spurline/spur.rb +213 -0
- data/lib/spurline/streaming/buffer.rb +77 -0
- data/lib/spurline/streaming/chunk.rb +62 -0
- data/lib/spurline/streaming/stream_enumerator.rb +29 -0
- data/lib/spurline/testing.rb +245 -0
- data/lib/spurline/toolkit.rb +110 -0
- data/lib/spurline/tools/base.rb +209 -0
- data/lib/spurline/tools/idempotency.rb +220 -0
- data/lib/spurline/tools/permissions.rb +44 -0
- data/lib/spurline/tools/registry.rb +43 -0
- data/lib/spurline/tools/runner.rb +255 -0
- data/lib/spurline/tools/scope.rb +309 -0
- data/lib/spurline/tools/toolkit_registry.rb +63 -0
- data/lib/spurline/version.rb +5 -0
- data/lib/spurline.rb +56 -0
- metadata +160 -0
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module Audit
|
|
6
|
+
# Stateless redaction utility for tool-call argument payloads.
|
|
7
|
+
module SecretFilter
|
|
8
|
+
SENSITIVE_PATTERNS = %w[
|
|
9
|
+
key token secret password credential passphrase
|
|
10
|
+
api_key api_secret access_token refresh_token
|
|
11
|
+
auth bearer jwt private_key
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Returns a filtered copy of arguments with sensitive values redacted.
|
|
16
|
+
# Never mutates the original object.
|
|
17
|
+
def filter(arguments, tool_name:, registry: nil)
|
|
18
|
+
return nil if arguments.nil?
|
|
19
|
+
|
|
20
|
+
sensitive_fields = sensitive_parameters_for(tool_name, registry)
|
|
21
|
+
filter_value(arguments, sensitive_fields)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns true when any sensitive key is present in arguments.
|
|
25
|
+
def contains_secrets?(arguments, tool_name:, registry: nil)
|
|
26
|
+
return false if arguments.nil?
|
|
27
|
+
|
|
28
|
+
sensitive_fields = sensitive_parameters_for(tool_name, registry)
|
|
29
|
+
contains_secrets_in_value?(arguments, sensitive_fields)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def filter_value(value, sensitive_fields)
|
|
35
|
+
case value
|
|
36
|
+
when Hash
|
|
37
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
38
|
+
key_name = key.to_s
|
|
39
|
+
if sensitive_key?(key_name, sensitive_fields)
|
|
40
|
+
out[key] = redacted_placeholder(key_name)
|
|
41
|
+
else
|
|
42
|
+
out[key] = filter_value(nested, sensitive_fields)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
when Array
|
|
46
|
+
value.map { |nested| filter_value(nested, sensitive_fields) }
|
|
47
|
+
else
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def contains_secrets_in_value?(value, sensitive_fields)
|
|
53
|
+
case value
|
|
54
|
+
when Hash
|
|
55
|
+
value.any? do |key, nested|
|
|
56
|
+
sensitive_key?(key.to_s, sensitive_fields) ||
|
|
57
|
+
contains_secrets_in_value?(nested, sensitive_fields)
|
|
58
|
+
end
|
|
59
|
+
when Array
|
|
60
|
+
value.any? { |nested| contains_secrets_in_value?(nested, sensitive_fields) }
|
|
61
|
+
else
|
|
62
|
+
false
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sensitive_key?(name, sensitive_fields)
|
|
67
|
+
normalized = normalize_key(name)
|
|
68
|
+
return true if sensitive_fields.include?(normalized)
|
|
69
|
+
|
|
70
|
+
tokens = normalized.split("_")
|
|
71
|
+
SENSITIVE_PATTERNS.any? do |pattern|
|
|
72
|
+
pattern_tokens = pattern.split("_")
|
|
73
|
+
if pattern_tokens.length == 1
|
|
74
|
+
tokens.include?(pattern)
|
|
75
|
+
else
|
|
76
|
+
tokens.each_cons(pattern_tokens.length).any? { |slice| slice == pattern_tokens }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def sensitive_parameters_for(tool_name, registry)
|
|
82
|
+
tool = resolve_tool(tool_name, registry)
|
|
83
|
+
return Set.new unless tool&.respond_to?(:sensitive_parameters)
|
|
84
|
+
|
|
85
|
+
raw = tool.sensitive_parameters || Set.new
|
|
86
|
+
Set.new(raw.map { |name| normalize_key(name) })
|
|
87
|
+
rescue StandardError
|
|
88
|
+
Set.new
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def resolve_tool(tool_name, registry)
|
|
92
|
+
return nil unless registry && tool_name
|
|
93
|
+
|
|
94
|
+
if registry.respond_to?(:registered?) && !registry.registered?(tool_name)
|
|
95
|
+
return nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
tool = registry.fetch(tool_name)
|
|
99
|
+
return tool.class unless tool.is_a?(Class)
|
|
100
|
+
|
|
101
|
+
tool
|
|
102
|
+
rescue StandardError
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_key(name)
|
|
107
|
+
name.to_s
|
|
108
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1_\2')
|
|
109
|
+
.strip
|
|
110
|
+
.downcase
|
|
111
|
+
.gsub(/[^a-z0-9]+/, "_")
|
|
112
|
+
.gsub(/\A_+|_+\z/, "")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def redacted_placeholder(field_name)
|
|
116
|
+
"[REDACTED:#{field_name}]"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|