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,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
|