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,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "pathname"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module DSL
|
|
6
|
+
# DSL for declaring which tools an agent can use.
|
|
7
|
+
# Registers configuration at class load time — never executes behavior.
|
|
8
|
+
#
|
|
9
|
+
# Supports per-tool config overrides:
|
|
10
|
+
# tools :web_search, file_delete: { requires_confirmation: true, timeout: 30 }
|
|
11
|
+
module Tools
|
|
12
|
+
def self.included(base)
|
|
13
|
+
base.extend(ClassMethods)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module ClassMethods
|
|
17
|
+
IDEMPOTENCY_OPTION_KEYS = %i[
|
|
18
|
+
idempotent
|
|
19
|
+
idempotency_key
|
|
20
|
+
idempotency_ttl
|
|
21
|
+
idempotency_key_fn
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def tools(*tool_names, **tool_configs)
|
|
25
|
+
@tool_config ||= { names: [], configs: {} }
|
|
26
|
+
tool_names.each { |name| @tool_config[:names] << name.to_sym }
|
|
27
|
+
tool_configs.each do |name, config|
|
|
28
|
+
@tool_config[:names] << name.to_sym
|
|
29
|
+
existing = @tool_config[:configs][name.to_sym]
|
|
30
|
+
@tool_config[:configs][name.to_sym] = existing ? existing.merge(config) : config
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Include one or more toolkits by name. Toolkit expansion is deferred
|
|
35
|
+
# until tool_config is accessed, so toolkits can be registered after
|
|
36
|
+
# agent classes are defined (supports any boot order).
|
|
37
|
+
#
|
|
38
|
+
# toolkits :git, :linear
|
|
39
|
+
# toolkits :provisioning, provisioning: { scoped: true }
|
|
40
|
+
#
|
|
41
|
+
def toolkits(*toolkit_names, **overrides)
|
|
42
|
+
@pending_toolkits ||= []
|
|
43
|
+
@pending_toolkits << { names: toolkit_names.map(&:to_sym), overrides: overrides }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tool_config
|
|
47
|
+
expand_pending_toolkits!
|
|
48
|
+
own = @tool_config || { names: [], configs: {} }
|
|
49
|
+
if superclass.respond_to?(:tool_config)
|
|
50
|
+
inherited = superclass.tool_config
|
|
51
|
+
{
|
|
52
|
+
names: (inherited[:names] + own[:names]).uniq,
|
|
53
|
+
configs: inherited[:configs].merge(own[:configs]),
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
own
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Returns per-tool configuration for a specific tool.
|
|
61
|
+
def tool_config_for(tool_name)
|
|
62
|
+
tool_config[:configs][tool_name.to_sym] || {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Effective per-tool idempotency options from DSL config.
|
|
66
|
+
def idempotency_config
|
|
67
|
+
tool_config[:configs].each_with_object({}) do |(tool_name, config), result|
|
|
68
|
+
next unless config.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
options = symbolize_hash(config).slice(*IDEMPOTENCY_OPTION_KEYS)
|
|
71
|
+
next if options.empty?
|
|
72
|
+
|
|
73
|
+
result[tool_name.to_sym] = options
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Effective permissions applied by Tools::Runner.
|
|
78
|
+
# Merge order: spur defaults -> agent inline config -> YAML overrides.
|
|
79
|
+
def permissions_config
|
|
80
|
+
merged = {}
|
|
81
|
+
deep_merge_permissions!(merged, spur_default_permissions)
|
|
82
|
+
deep_merge_permissions!(merged, inline_tool_permissions)
|
|
83
|
+
deep_merge_permissions!(merged, yaml_permissions)
|
|
84
|
+
merged
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def expand_pending_toolkits!
|
|
90
|
+
return unless instance_variable_defined?(:@pending_toolkits) && @pending_toolkits&.any?
|
|
91
|
+
|
|
92
|
+
pending = @pending_toolkits
|
|
93
|
+
@pending_toolkits = nil
|
|
94
|
+
|
|
95
|
+
pending.each do |ref|
|
|
96
|
+
ref[:names].each do |tk_name|
|
|
97
|
+
toolkit = self.toolkit_registry.fetch(tk_name)
|
|
98
|
+
|
|
99
|
+
# Toolkits own their tools — register them into the agent's tool registry.
|
|
100
|
+
toolkit.tool_classes.each do |tool_name, tool_class|
|
|
101
|
+
self.tool_registry.register(tool_name, tool_class) unless self.tool_registry.registered?(tool_name)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
tool_names = toolkit.tools
|
|
105
|
+
shared = toolkit.shared_config
|
|
106
|
+
|
|
107
|
+
if shared.empty? && ref[:overrides].empty?
|
|
108
|
+
tools(*tool_names)
|
|
109
|
+
else
|
|
110
|
+
tool_configs = {}
|
|
111
|
+
tool_names.each do |tool_name|
|
|
112
|
+
merged = shared.dup
|
|
113
|
+
merged.merge!(ref[:overrides][tk_name] || {})
|
|
114
|
+
tool_configs[tool_name] = merged unless merged.empty?
|
|
115
|
+
end
|
|
116
|
+
tools(*tool_names, **tool_configs)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def spur_default_permissions
|
|
123
|
+
return {} unless defined?(Spurline::Spur)
|
|
124
|
+
|
|
125
|
+
Spurline::Spur.registry.each_with_object({}) do |(_name, info), result|
|
|
126
|
+
next unless info.is_a?(Hash)
|
|
127
|
+
|
|
128
|
+
defaults = symbolize_hash(info[:permissions] || info["permissions"])
|
|
129
|
+
next if defaults.empty?
|
|
130
|
+
|
|
131
|
+
tools = info[:tools] || info["tools"] || []
|
|
132
|
+
tools.each do |tool_name|
|
|
133
|
+
result[tool_name.to_sym] ||= {}
|
|
134
|
+
result[tool_name.to_sym].merge!(defaults)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def inline_tool_permissions
|
|
140
|
+
tool_config[:configs].each_with_object({}) do |(tool_name, config), result|
|
|
141
|
+
result[tool_name.to_sym] = symbolize_hash(config)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def yaml_permissions
|
|
146
|
+
path = resolve_permissions_path
|
|
147
|
+
Spurline::Tools::Permissions.load_file(path)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve_permissions_path
|
|
151
|
+
configured = Spurline.config.permissions_file
|
|
152
|
+
return nil if configured.nil? || configured.to_s.strip.empty?
|
|
153
|
+
return configured if Pathname.new(configured).absolute?
|
|
154
|
+
|
|
155
|
+
File.expand_path(configured.to_s, Dir.pwd)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def deep_merge_permissions!(base, incoming)
|
|
159
|
+
incoming.each do |tool_name, tool_config_hash|
|
|
160
|
+
key = tool_name.to_sym
|
|
161
|
+
base[key] ||= {}
|
|
162
|
+
base[key].merge!(symbolize_hash(tool_config_hash))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def symbolize_hash(value)
|
|
167
|
+
return {} unless value.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
value.each_with_object({}) do |(k, v), result|
|
|
170
|
+
result[k.to_sym] = v
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Eagerly loaded by lib/spurline.rb and ignored by Zeitwerk.
|
|
4
|
+
# This is the one file that breaks the autoloading convention, because
|
|
5
|
+
# error classes must be available before any framework code runs and
|
|
6
|
+
# they define multiple constants directly under Spurline.
|
|
7
|
+
|
|
8
|
+
module Spurline
|
|
9
|
+
# Base error for all Spurline errors. Rescue this to catch any framework error.
|
|
10
|
+
class AgentError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised when tainted content (trust: :external or :untrusted) is converted
|
|
13
|
+
# to a string via #to_s. Use Content#render instead, which applies data fencing.
|
|
14
|
+
class TaintedContentError < AgentError; end
|
|
15
|
+
|
|
16
|
+
# Raised when the injection scanner detects a prompt injection pattern
|
|
17
|
+
# in content flowing through the context pipeline.
|
|
18
|
+
class InjectionAttemptError < AgentError; end
|
|
19
|
+
|
|
20
|
+
# Raised when the PII filter in :block mode detects personally identifiable
|
|
21
|
+
# information in content. Switch to :redact mode to allow content through
|
|
22
|
+
# with PII replaced, or :off to disable filtering.
|
|
23
|
+
class PIIDetectedError < AgentError; end
|
|
24
|
+
|
|
25
|
+
# Raised when a tool execution is denied by the permission system.
|
|
26
|
+
# Check config/permissions.yml for the tool's permission requirements.
|
|
27
|
+
class PermissionDeniedError < AgentError; end
|
|
28
|
+
|
|
29
|
+
# Raised when code attempts to modify a compiled persona at runtime.
|
|
30
|
+
# Personas are frozen on class load — define a new persona instead.
|
|
31
|
+
class PersonaFrozenError < AgentError; end
|
|
32
|
+
|
|
33
|
+
# Raised when an agent attempts an invalid lifecycle state transition.
|
|
34
|
+
# Check Spurline::Lifecycle::States for valid transitions.
|
|
35
|
+
class InvalidStateError < AgentError; end
|
|
36
|
+
|
|
37
|
+
# Raised when a tool call references a tool name not in the registry.
|
|
38
|
+
# Ensure the tool's spur gem is installed and required.
|
|
39
|
+
class ToolNotFoundError < AgentError; end
|
|
40
|
+
|
|
41
|
+
# Raised when the per-turn tool call limit (guardrails.max_tool_calls) is exceeded.
|
|
42
|
+
# Increase the limit in the agent's guardrails block or restructure the task.
|
|
43
|
+
class MaxToolCallsError < AgentError; end
|
|
44
|
+
|
|
45
|
+
# Raised when a tool attempts to invoke another tool. Tools are leaf nodes (ADR-003).
|
|
46
|
+
# Use a Spurline::Skill if you need to compose multiple tools.
|
|
47
|
+
class NestedToolCallError < AgentError; end
|
|
48
|
+
|
|
49
|
+
# Raised when an adapter symbol cannot be resolved in the adapter registry.
|
|
50
|
+
# Ensure the adapter is registered before referencing it in use_model.
|
|
51
|
+
class AdapterNotFoundError < AgentError; end
|
|
52
|
+
|
|
53
|
+
# Raised when the sqlite3 gem is unavailable but the SQLite session store is used.
|
|
54
|
+
# Add gem "sqlite3" to the application bundle when configuring :sqlite session storage.
|
|
55
|
+
class SQLiteUnavailableError < AgentError; end
|
|
56
|
+
|
|
57
|
+
# Raised when the pg gem is unavailable but the Postgres session store is used.
|
|
58
|
+
# Add gem "pg" to the application bundle when configuring :postgres session storage.
|
|
59
|
+
class PostgresUnavailableError < AgentError; end
|
|
60
|
+
|
|
61
|
+
# Raised when persisted session payloads cannot be decoded into Session/Turn objects.
|
|
62
|
+
# This indicates corrupted or incompatible serialized session data.
|
|
63
|
+
class SessionDeserializationError < AgentError; end
|
|
64
|
+
|
|
65
|
+
# Raised when Spurline.configure or a DSL method receives invalid configuration.
|
|
66
|
+
# This always fires at class load time, never at runtime.
|
|
67
|
+
class ConfigurationError < AgentError; end
|
|
68
|
+
|
|
69
|
+
# Raised when encrypted credentials exist but no master key can be resolved.
|
|
70
|
+
class CredentialsMissingKeyError < AgentError; end
|
|
71
|
+
|
|
72
|
+
# Raised when encrypted credentials cannot be decrypted (bad key or tampered file).
|
|
73
|
+
class CredentialsDecryptionError < AgentError; end
|
|
74
|
+
|
|
75
|
+
# Raised when a required tool secret cannot be resolved from any configured source.
|
|
76
|
+
class SecretNotFoundError < AgentError; end
|
|
77
|
+
|
|
78
|
+
# Raised when an embedding provider or model fails to produce a valid vector.
|
|
79
|
+
class EmbedderError < AgentError; end
|
|
80
|
+
|
|
81
|
+
# Raised when long-term memory persistence or retrieval fails.
|
|
82
|
+
class LongTermMemoryError < AgentError; end
|
|
83
|
+
|
|
84
|
+
# Raised when a session cannot be suspended from its current state.
|
|
85
|
+
class SuspensionError < AgentError; end
|
|
86
|
+
|
|
87
|
+
# Raised when a resume is attempted for a non-suspended session.
|
|
88
|
+
class InvalidResumeError < AgentError; end
|
|
89
|
+
|
|
90
|
+
# Raised when Cartographer cannot access the target repository path.
|
|
91
|
+
class CartographerAccessError < AgentError; end
|
|
92
|
+
|
|
93
|
+
# Raised when an individual analyzer fails to produce valid output.
|
|
94
|
+
class AnalyzerError < AgentError; end
|
|
95
|
+
class ScopeViolationError < AgentError; end
|
|
96
|
+
class IdempotencyKeyConflictError < AgentError; end
|
|
97
|
+
class PrivilegeEscalationError < AgentError; end
|
|
98
|
+
class LedgerError < AgentError; end
|
|
99
|
+
class TaskEnvelopeError < AgentError; end
|
|
100
|
+
class MergeConflictError < AgentError; end
|
|
101
|
+
|
|
102
|
+
# Raised when a spawned child agent fails during execution.
|
|
103
|
+
# The message includes parent/child session IDs for audit correlation.
|
|
104
|
+
class SpawnError < AgentError; end
|
|
105
|
+
|
|
106
|
+
# Raised when a toolkit name cannot be resolved in the toolkit registry.
|
|
107
|
+
# Ensure the toolkit class is defined and loaded before referencing it.
|
|
108
|
+
class ToolkitNotFoundError < AgentError; end
|
|
109
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
| #3632 | 6:00 PM | ⚖️ | OpenAI adapter architecture with stop reason normalization and tool call accumulation | ~541 |
|
|
12
|
+
|
|
13
|
+
### Feb 23, 2026
|
|
14
|
+
|
|
15
|
+
| ID | Time | T | Title | Read |
|
|
16
|
+
|----|------|---|-------|------|
|
|
17
|
+
| #4214 | 12:07 AM | 🔴 | Fixed message history accumulation in LLM loop | ~327 |
|
|
18
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Lifecycle
|
|
5
|
+
# Executes a fixed sequence of tools without LLM involvement.
|
|
6
|
+
# This is the deterministic counterpart to Lifecycle::Runner.
|
|
7
|
+
#
|
|
8
|
+
# Each tool in the sequence receives accumulated results from previous tools.
|
|
9
|
+
#
|
|
10
|
+
# Stop conditions:
|
|
11
|
+
# - All tools in the sequence have executed
|
|
12
|
+
# - max_tool_calls guardrail exceeded
|
|
13
|
+
# - A tool raises an error
|
|
14
|
+
class DeterministicRunner
|
|
15
|
+
def initialize(
|
|
16
|
+
tool_runner:,
|
|
17
|
+
audit_log:,
|
|
18
|
+
session:,
|
|
19
|
+
guardrails: {},
|
|
20
|
+
scope: nil,
|
|
21
|
+
idempotency_ledger: nil
|
|
22
|
+
)
|
|
23
|
+
@tool_runner = tool_runner
|
|
24
|
+
@audit_log = audit_log
|
|
25
|
+
@session = session
|
|
26
|
+
@guardrails = guardrails
|
|
27
|
+
@scope = scope
|
|
28
|
+
@idempotency_ledger = idempotency_ledger
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ASYNC-READY: executes tools sequentially, each is a blocking boundary
|
|
32
|
+
def run(tool_sequence:, input:, session:, &chunk_handler)
|
|
33
|
+
turn = session.start_turn(input: input)
|
|
34
|
+
@audit_log.record(:turn_start, turn: turn.number)
|
|
35
|
+
|
|
36
|
+
results = {}
|
|
37
|
+
|
|
38
|
+
tool_sequence.each_with_index do |step, idx|
|
|
39
|
+
tool_name, arguments = resolve_step(step, results, input)
|
|
40
|
+
check_max_tool_calls!(session)
|
|
41
|
+
|
|
42
|
+
filtered_arguments = redact_arguments(tool_name, arguments)
|
|
43
|
+
chunk_handler&.call(
|
|
44
|
+
Streaming::Chunk.new(
|
|
45
|
+
type: :tool_start,
|
|
46
|
+
turn: turn.number,
|
|
47
|
+
session_id: session.id,
|
|
48
|
+
metadata: { tool_name: tool_name.to_s, arguments: filtered_arguments }
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
started = Time.now
|
|
53
|
+
tool_call = { name: tool_name.to_s, arguments: arguments }
|
|
54
|
+
result = @tool_runner.execute(
|
|
55
|
+
tool_call,
|
|
56
|
+
session: session,
|
|
57
|
+
scope: @scope,
|
|
58
|
+
idempotency_ledger: @idempotency_ledger
|
|
59
|
+
)
|
|
60
|
+
duration_ms = ((Time.now - started) * 1000).round
|
|
61
|
+
|
|
62
|
+
@audit_log.record(
|
|
63
|
+
:tool_call,
|
|
64
|
+
tool: tool_name.to_s,
|
|
65
|
+
arguments: filtered_arguments,
|
|
66
|
+
duration_ms: duration_ms,
|
|
67
|
+
turn: turn.number,
|
|
68
|
+
step: idx + 1
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
chunk_handler&.call(
|
|
72
|
+
Streaming::Chunk.new(
|
|
73
|
+
type: :tool_end,
|
|
74
|
+
turn: turn.number,
|
|
75
|
+
session_id: session.id,
|
|
76
|
+
metadata: { tool_name: tool_name.to_s, duration_ms: duration_ms }
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
results[tool_name.to_sym] = result
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
output_text = build_output_summary(results)
|
|
84
|
+
output_content = Security::Gates::OperatorConfig.wrap(
|
|
85
|
+
output_text, key: "deterministic_result"
|
|
86
|
+
)
|
|
87
|
+
turn.finish!(output: output_content)
|
|
88
|
+
|
|
89
|
+
chunk_handler&.call(
|
|
90
|
+
Streaming::Chunk.new(
|
|
91
|
+
type: :done,
|
|
92
|
+
turn: turn.number,
|
|
93
|
+
session_id: session.id,
|
|
94
|
+
metadata: {
|
|
95
|
+
stop_reason: "deterministic_sequence_complete",
|
|
96
|
+
tool_count: tool_sequence.length,
|
|
97
|
+
}
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@audit_log.record(
|
|
102
|
+
:turn_end,
|
|
103
|
+
turn: turn.number,
|
|
104
|
+
duration_ms: turn.duration_ms,
|
|
105
|
+
tool_calls: turn.tool_call_count,
|
|
106
|
+
mode: :deterministic
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
results
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
# Resolves a step definition into a tool name and arguments hash.
|
|
115
|
+
#
|
|
116
|
+
# Steps can be:
|
|
117
|
+
# - Symbol: tool name with default arguments (passes input through)
|
|
118
|
+
# - Hash with :name and :arguments (static args)
|
|
119
|
+
# - Hash with :name and Proc/Lambda :arguments (dynamic args)
|
|
120
|
+
def resolve_step(step, results_so_far, input)
|
|
121
|
+
case step
|
|
122
|
+
when Symbol
|
|
123
|
+
[step, { input: serialize_input(input) }]
|
|
124
|
+
when Hash
|
|
125
|
+
name = step[:name] || step[:tool]
|
|
126
|
+
unless name
|
|
127
|
+
raise Spurline::ConfigurationError,
|
|
128
|
+
"Deterministic sequence step must have a :name or :tool key. " \
|
|
129
|
+
"Got: #{step.inspect}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
args = step[:arguments] || step[:args]
|
|
133
|
+
[name.to_sym, resolve_arguments(args, results_so_far, input)]
|
|
134
|
+
else
|
|
135
|
+
raise Spurline::ConfigurationError,
|
|
136
|
+
"Deterministic sequence step must be a Symbol or Hash. " \
|
|
137
|
+
"Got: #{step.class} (#{step.inspect})"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def resolve_arguments(args, results_so_far, input)
|
|
142
|
+
case args
|
|
143
|
+
when Proc
|
|
144
|
+
resolved = args.call(results_so_far, input)
|
|
145
|
+
unless resolved.is_a?(Hash)
|
|
146
|
+
raise Spurline::ConfigurationError,
|
|
147
|
+
"Tool arguments proc/lambda must return a Hash. " \
|
|
148
|
+
"Got: #{resolved.class} (#{resolved.inspect})"
|
|
149
|
+
end
|
|
150
|
+
resolved
|
|
151
|
+
when Hash
|
|
152
|
+
args
|
|
153
|
+
when nil
|
|
154
|
+
{ input: serialize_input(input) }
|
|
155
|
+
else
|
|
156
|
+
raise Spurline::ConfigurationError,
|
|
157
|
+
"Tool arguments must be a Hash, Proc/Lambda, or nil. " \
|
|
158
|
+
"Got: #{args.class} (#{args.inspect})"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def serialize_input(input)
|
|
163
|
+
if input.is_a?(Security::Content)
|
|
164
|
+
input.respond_to?(:render) ? input.render : input.text
|
|
165
|
+
else
|
|
166
|
+
input.to_s
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def check_max_tool_calls!(session)
|
|
171
|
+
max = resolve_max_tool_calls
|
|
172
|
+
return if session.tool_call_count < max
|
|
173
|
+
|
|
174
|
+
@audit_log.record(:max_tool_calls_reached, limit: max)
|
|
175
|
+
raise Spurline::MaxToolCallsError,
|
|
176
|
+
"Tool call limit reached (#{max}). " \
|
|
177
|
+
"Increase max_tool_calls in the agent's guardrails block."
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def resolve_max_tool_calls
|
|
181
|
+
@guardrails[:max_tool_calls] || 10
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def redact_arguments(tool_name, arguments)
|
|
185
|
+
Audit::SecretFilter.filter(
|
|
186
|
+
arguments,
|
|
187
|
+
tool_name: tool_name.to_s,
|
|
188
|
+
registry: @tool_runner.registry
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def build_output_summary(results)
|
|
193
|
+
results.map do |tool_name, result|
|
|
194
|
+
text =
|
|
195
|
+
if result.respond_to?(:render)
|
|
196
|
+
result.render
|
|
197
|
+
elsif result.respond_to?(:text)
|
|
198
|
+
result.text.to_s
|
|
199
|
+
else
|
|
200
|
+
result.inspect
|
|
201
|
+
end
|
|
202
|
+
"#{tool_name}: #{text[0..200]}"
|
|
203
|
+
end.join("\n")
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|