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,456 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require_relative "suspension_boundary"
|
|
6
|
+
|
|
7
|
+
module Spurline
|
|
8
|
+
module Lifecycle
|
|
9
|
+
# The LLM call loop. Orchestrates context assembly, streaming, tool execution,
|
|
10
|
+
# and stop condition checking. This is the core engine of the framework.
|
|
11
|
+
#
|
|
12
|
+
# Stop conditions:
|
|
13
|
+
# - Text response from the LLM (normal completion)
|
|
14
|
+
# - max_tool_calls exceeded
|
|
15
|
+
# - max_turns exceeded (multi-loop safety valve)
|
|
16
|
+
class Runner
|
|
17
|
+
def initialize(
|
|
18
|
+
adapter:,
|
|
19
|
+
pipeline:,
|
|
20
|
+
tool_runner:,
|
|
21
|
+
memory:,
|
|
22
|
+
assembler:,
|
|
23
|
+
audit:,
|
|
24
|
+
guardrails:,
|
|
25
|
+
suspension_check: nil,
|
|
26
|
+
scope: nil,
|
|
27
|
+
idempotency_ledger: nil
|
|
28
|
+
)
|
|
29
|
+
@adapter = adapter
|
|
30
|
+
@pipeline = pipeline
|
|
31
|
+
@tool_runner = tool_runner
|
|
32
|
+
@memory = memory
|
|
33
|
+
@assembler = assembler
|
|
34
|
+
@audit = audit
|
|
35
|
+
@guardrails = guardrails
|
|
36
|
+
@suspension_check = suspension_check || Lifecycle::SuspensionCheck.none
|
|
37
|
+
@scope = scope
|
|
38
|
+
@idempotency_ledger = idempotency_ledger
|
|
39
|
+
@loop_count = 0
|
|
40
|
+
@messages_so_far = []
|
|
41
|
+
@last_tool_result = nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ASYNC-READY: the main call loop
|
|
45
|
+
def run(
|
|
46
|
+
input:,
|
|
47
|
+
session:,
|
|
48
|
+
persona:,
|
|
49
|
+
tools_schema:,
|
|
50
|
+
adapter_config:,
|
|
51
|
+
agent_context: nil,
|
|
52
|
+
resume_checkpoint: nil,
|
|
53
|
+
&chunk_handler
|
|
54
|
+
)
|
|
55
|
+
turn, input, parent_episode_id = initialize_turn_context(
|
|
56
|
+
session: session,
|
|
57
|
+
input: input,
|
|
58
|
+
resume_checkpoint: resume_checkpoint
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
loop do
|
|
62
|
+
@loop_count += 1
|
|
63
|
+
check_max_turns!
|
|
64
|
+
|
|
65
|
+
# 1. Assemble context
|
|
66
|
+
contents = @assembler.assemble(
|
|
67
|
+
input: input,
|
|
68
|
+
memory: @memory,
|
|
69
|
+
persona: persona,
|
|
70
|
+
session: session,
|
|
71
|
+
agent_context: agent_context
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# 2. Process through security pipeline
|
|
75
|
+
processed = @pipeline.process(contents)
|
|
76
|
+
|
|
77
|
+
# Separate system prompt from messages while preserving role semantics.
|
|
78
|
+
system_prompt, messages = build_messages(contents, processed, input)
|
|
79
|
+
|
|
80
|
+
# On the first loop, seed @messages_so_far with the initial messages.
|
|
81
|
+
# On subsequent loops, the conversation history is already accumulated
|
|
82
|
+
# via assistant tool_use and tool_result entries appended below.
|
|
83
|
+
if @loop_count == 1
|
|
84
|
+
@messages_so_far = messages.map(&:dup)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
check_suspension!(
|
|
88
|
+
boundary_type: :before_llm_call,
|
|
89
|
+
turn: turn,
|
|
90
|
+
context: {
|
|
91
|
+
loop_iteration: @loop_count,
|
|
92
|
+
turn_number: turn.number,
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# 3. Stream LLM response
|
|
97
|
+
buffer = Streaming::Buffer.new
|
|
98
|
+
@audit.record(:llm_request,
|
|
99
|
+
turn: turn.number,
|
|
100
|
+
loop: @loop_count,
|
|
101
|
+
message_count: @messages_so_far.length,
|
|
102
|
+
has_tools: !tools_schema.empty?,
|
|
103
|
+
tool_count: tools_schema.length)
|
|
104
|
+
|
|
105
|
+
@adapter.stream(
|
|
106
|
+
messages: @messages_so_far,
|
|
107
|
+
system: system_prompt,
|
|
108
|
+
tools: tools_schema,
|
|
109
|
+
config: adapter_config
|
|
110
|
+
) do |chunk|
|
|
111
|
+
buffer << chunk
|
|
112
|
+
chunk_handler&.call(chunk) if chunk.text?
|
|
113
|
+
end
|
|
114
|
+
@audit.record(:llm_response,
|
|
115
|
+
turn: turn.number,
|
|
116
|
+
loop: @loop_count,
|
|
117
|
+
stop_reason: buffer.stop_reason,
|
|
118
|
+
tool_call_count: buffer.tool_call_count,
|
|
119
|
+
text_length: buffer.full_text.length)
|
|
120
|
+
|
|
121
|
+
# 4. Parse response
|
|
122
|
+
if buffer.tool_call?
|
|
123
|
+
tool_calls = buffer.tool_calls
|
|
124
|
+
|
|
125
|
+
# Append assistant tool_use message to conversation history
|
|
126
|
+
tool_use_blocks = tool_calls.map do |tc|
|
|
127
|
+
tool_use_id = find_tool_use_id(buffer, tc[:name])
|
|
128
|
+
{
|
|
129
|
+
type: "tool_use",
|
|
130
|
+
id: tool_use_id || "toolu_#{SecureRandom.hex(12)}",
|
|
131
|
+
name: tc[:name],
|
|
132
|
+
input: tc[:arguments] || {},
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
@messages_so_far << { role: "assistant", content: tool_use_blocks }
|
|
136
|
+
|
|
137
|
+
tool_calls.each do |tool_call|
|
|
138
|
+
filtered_args = Audit::SecretFilter.filter(
|
|
139
|
+
tool_call[:arguments],
|
|
140
|
+
tool_name: tool_call[:name],
|
|
141
|
+
registry: @tool_runner.registry
|
|
142
|
+
)
|
|
143
|
+
decision_episode_id = record_episode(
|
|
144
|
+
session: session,
|
|
145
|
+
turn: turn,
|
|
146
|
+
type: :decision,
|
|
147
|
+
content: "Model requested tool call",
|
|
148
|
+
metadata: {
|
|
149
|
+
decision: "invoke_tool",
|
|
150
|
+
tool_name: tool_call[:name],
|
|
151
|
+
arguments: filtered_args,
|
|
152
|
+
loop: @loop_count,
|
|
153
|
+
},
|
|
154
|
+
parent_episode_id: parent_episode_id
|
|
155
|
+
)
|
|
156
|
+
tool_episode_id = record_episode(
|
|
157
|
+
session: session,
|
|
158
|
+
turn: turn,
|
|
159
|
+
type: :tool_call,
|
|
160
|
+
content: filtered_args,
|
|
161
|
+
metadata: {
|
|
162
|
+
tool_name: tool_call[:name],
|
|
163
|
+
loop: @loop_count,
|
|
164
|
+
},
|
|
165
|
+
parent_episode_id: decision_episode_id || parent_episode_id
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Check guardrails
|
|
169
|
+
if session.tool_call_count >= @guardrails[:max_tool_calls]
|
|
170
|
+
@audit.record(:max_tool_calls_reached,
|
|
171
|
+
limit: @guardrails[:max_tool_calls])
|
|
172
|
+
raise Spurline::MaxToolCallsError,
|
|
173
|
+
"Tool call limit reached (#{@guardrails[:max_tool_calls]}). " \
|
|
174
|
+
"Increase max_tool_calls in the agent's guardrails block."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Yield tool_start chunk
|
|
178
|
+
chunk_handler&.call(
|
|
179
|
+
Streaming::Chunk.new(
|
|
180
|
+
type: :tool_start,
|
|
181
|
+
turn: turn.number,
|
|
182
|
+
session_id: session.id,
|
|
183
|
+
metadata: { tool_name: tool_call[:name], arguments: filtered_args }
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# 5. Execute tool
|
|
188
|
+
started = Time.now
|
|
189
|
+
result = @tool_runner.execute(
|
|
190
|
+
tool_call,
|
|
191
|
+
session: session,
|
|
192
|
+
scope: @scope,
|
|
193
|
+
idempotency_ledger: @idempotency_ledger
|
|
194
|
+
)
|
|
195
|
+
duration_ms = ((Time.now - started) * 1000).round
|
|
196
|
+
|
|
197
|
+
@audit.record(:tool_call,
|
|
198
|
+
tool: tool_call[:name],
|
|
199
|
+
arguments: tool_call[:arguments],
|
|
200
|
+
duration_ms: duration_ms,
|
|
201
|
+
turn: turn.number,
|
|
202
|
+
loop: @loop_count)
|
|
203
|
+
|
|
204
|
+
# Yield tool_end chunk
|
|
205
|
+
chunk_handler&.call(
|
|
206
|
+
Streaming::Chunk.new(
|
|
207
|
+
type: :tool_end,
|
|
208
|
+
turn: turn.number,
|
|
209
|
+
session_id: session.id,
|
|
210
|
+
metadata: { tool_name: tool_call[:name], duration_ms: duration_ms }
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
@audit.record(:tool_result,
|
|
215
|
+
tool: tool_call[:name],
|
|
216
|
+
turn: turn.number,
|
|
217
|
+
loop: @loop_count,
|
|
218
|
+
result_length: result.text.to_s.length,
|
|
219
|
+
trust: result.trust)
|
|
220
|
+
parent_episode_id = record_episode(
|
|
221
|
+
session: session,
|
|
222
|
+
turn: turn,
|
|
223
|
+
type: :external_data,
|
|
224
|
+
content: result,
|
|
225
|
+
metadata: {
|
|
226
|
+
source: "tool:#{tool_call[:name]}",
|
|
227
|
+
trust: result.trust,
|
|
228
|
+
loop: @loop_count,
|
|
229
|
+
},
|
|
230
|
+
parent_episode_id: tool_episode_id || decision_episode_id || parent_episode_id
|
|
231
|
+
) || parent_episode_id
|
|
232
|
+
|
|
233
|
+
# 6. Append tool_result to conversation history for the LLM
|
|
234
|
+
tool_use_id = find_tool_use_id(buffer, tool_call[:name])
|
|
235
|
+
result_text = result.respond_to?(:text) ? result.text : result.to_s
|
|
236
|
+
result_content = result_text.is_a?(String) ? result_text : JSON.generate(result_text)
|
|
237
|
+
@messages_so_far << {
|
|
238
|
+
role: "user",
|
|
239
|
+
content: [{
|
|
240
|
+
type: "tool_result",
|
|
241
|
+
tool_use_id: tool_use_id || "toolu_#{SecureRandom.hex(12)}",
|
|
242
|
+
content: result_content,
|
|
243
|
+
}],
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
@last_tool_result = serialize_tool_result(result)
|
|
247
|
+
check_suspension!(
|
|
248
|
+
boundary_type: :after_tool_result,
|
|
249
|
+
turn: turn,
|
|
250
|
+
context: {
|
|
251
|
+
loop_iteration: @loop_count,
|
|
252
|
+
turn_number: turn.number,
|
|
253
|
+
tool_name: tool_call[:name],
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
input = result
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Continue the loop
|
|
260
|
+
next
|
|
261
|
+
else
|
|
262
|
+
# Text response — the agent is done
|
|
263
|
+
output_text = buffer.full_text
|
|
264
|
+
output_content = Security::Gates::OperatorConfig.wrap(
|
|
265
|
+
output_text, key: "llm_response"
|
|
266
|
+
)
|
|
267
|
+
decision_episode_id = record_episode(
|
|
268
|
+
session: session,
|
|
269
|
+
turn: turn,
|
|
270
|
+
type: :decision,
|
|
271
|
+
content: "Model returned final response",
|
|
272
|
+
metadata: {
|
|
273
|
+
decision: "final_response",
|
|
274
|
+
stop_reason: buffer.stop_reason,
|
|
275
|
+
loop: @loop_count,
|
|
276
|
+
},
|
|
277
|
+
parent_episode_id: parent_episode_id
|
|
278
|
+
)
|
|
279
|
+
record_episode(
|
|
280
|
+
session: session,
|
|
281
|
+
turn: turn,
|
|
282
|
+
type: :assistant_response,
|
|
283
|
+
content: output_content,
|
|
284
|
+
metadata: {
|
|
285
|
+
source: output_content.source,
|
|
286
|
+
trust: output_content.trust,
|
|
287
|
+
loop: @loop_count,
|
|
288
|
+
},
|
|
289
|
+
parent_episode_id: decision_episode_id || parent_episode_id
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
turn.finish!(output: output_content)
|
|
293
|
+
@memory.add_turn(turn)
|
|
294
|
+
|
|
295
|
+
# Yield done chunk
|
|
296
|
+
chunk_handler&.call(
|
|
297
|
+
Streaming::Chunk.new(
|
|
298
|
+
type: :done,
|
|
299
|
+
turn: turn.number,
|
|
300
|
+
session_id: session.id,
|
|
301
|
+
metadata: { stop_reason: "end_turn" }
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
@audit.record(:turn_end, turn: turn.number,
|
|
306
|
+
duration_ms: turn.duration_ms,
|
|
307
|
+
tool_calls: turn.tool_call_count)
|
|
308
|
+
break
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
private
|
|
314
|
+
|
|
315
|
+
def initialize_turn_context(session:, input:, resume_checkpoint:)
|
|
316
|
+
if resume_checkpoint
|
|
317
|
+
@loop_count = checkpoint_value(resume_checkpoint, :loop_iteration).to_i
|
|
318
|
+
@last_tool_result = checkpoint_value(resume_checkpoint, :last_tool_result)
|
|
319
|
+
@messages_so_far = Array(checkpoint_value(resume_checkpoint, :messages_so_far)).map do |entry|
|
|
320
|
+
entry.is_a?(Hash) ? entry.dup : entry
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
turn = session.current_turn
|
|
324
|
+
if turn.nil? || turn.complete?
|
|
325
|
+
turn = session.start_turn(input: input)
|
|
326
|
+
@audit.record(:turn_start, turn: turn.number)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
return [turn, input, nil]
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
@loop_count = 0
|
|
333
|
+
@last_tool_result = nil
|
|
334
|
+
@messages_so_far = []
|
|
335
|
+
|
|
336
|
+
turn = session.start_turn(input: input)
|
|
337
|
+
@audit.record(:turn_start, turn: turn.number)
|
|
338
|
+
parent_episode_id = record_episode(
|
|
339
|
+
session: session,
|
|
340
|
+
turn: turn,
|
|
341
|
+
type: :user_message,
|
|
342
|
+
content: input,
|
|
343
|
+
metadata: {
|
|
344
|
+
source: input.respond_to?(:source) ? input.source : "user:input",
|
|
345
|
+
trust: input.respond_to?(:trust) ? input.trust : :user,
|
|
346
|
+
}
|
|
347
|
+
)
|
|
348
|
+
[turn, input, parent_episode_id]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def check_suspension!(boundary_type:, turn:, context: {})
|
|
352
|
+
boundary = SuspensionBoundary.new(type: boundary_type, context: context)
|
|
353
|
+
decision = @suspension_check.call(boundary)
|
|
354
|
+
return if decision == :continue
|
|
355
|
+
|
|
356
|
+
raise SuspensionSignal.new(checkpoint: suspension_checkpoint(turn: turn, context: context))
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def suspension_checkpoint(turn:, context:)
|
|
360
|
+
{
|
|
361
|
+
loop_iteration: @loop_count,
|
|
362
|
+
last_tool_result: @last_tool_result,
|
|
363
|
+
messages_so_far: @messages_so_far.dup,
|
|
364
|
+
turn_number: turn.number,
|
|
365
|
+
suspended_at: Time.now.utc.iso8601,
|
|
366
|
+
suspension_reason: context[:suspension_reason],
|
|
367
|
+
}
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def checkpoint_value(checkpoint, key)
|
|
371
|
+
checkpoint[key] || checkpoint[key.to_s]
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def find_tool_use_id(buffer, tool_name)
|
|
375
|
+
chunk = buffer.chunks.find do |c|
|
|
376
|
+
c.metadata[:tool_name] == tool_name && c.metadata[:tool_use_id]
|
|
377
|
+
end
|
|
378
|
+
chunk&.metadata&.dig(:tool_use_id)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def serialize_tool_result(result)
|
|
382
|
+
return nil if result.nil?
|
|
383
|
+
return result.text if result.respond_to?(:text)
|
|
384
|
+
|
|
385
|
+
result.to_s
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def check_max_turns!
|
|
389
|
+
max = @guardrails[:max_turns] || 50
|
|
390
|
+
return if @loop_count <= max
|
|
391
|
+
|
|
392
|
+
raise Spurline::MaxToolCallsError,
|
|
393
|
+
"Loop iteration limit reached (#{max}). The agent has looped #{@loop_count} times " \
|
|
394
|
+
"without producing a final text response. Check tool behavior or increase max_turns."
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def build_messages(contents, processed, input)
|
|
398
|
+
system_parts = []
|
|
399
|
+
messages = []
|
|
400
|
+
|
|
401
|
+
contents.zip(processed).each do |content, rendered|
|
|
402
|
+
next unless rendered
|
|
403
|
+
|
|
404
|
+
if content.trust == :system
|
|
405
|
+
system_parts << rendered
|
|
406
|
+
next
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
messages << {
|
|
410
|
+
role: role_for(content),
|
|
411
|
+
content: rendered,
|
|
412
|
+
}
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Ensure at least one user message
|
|
416
|
+
if messages.empty?
|
|
417
|
+
text = input.is_a?(Security::Content) ? input.render : input.to_s
|
|
418
|
+
messages << { role: "user", content: text }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
[system_parts.join("\n\n"), messages]
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def role_for(content)
|
|
425
|
+
return "assistant" if content.source == "config:llm_response"
|
|
426
|
+
|
|
427
|
+
"user"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def record_episode(session:, turn:, type:, content:, metadata:, parent_episode_id: nil)
|
|
431
|
+
return nil unless @memory.respond_to?(:record_episode)
|
|
432
|
+
|
|
433
|
+
episode = @memory.record_episode(
|
|
434
|
+
type: type,
|
|
435
|
+
content: content,
|
|
436
|
+
metadata: metadata,
|
|
437
|
+
turn_number: turn.number,
|
|
438
|
+
parent_episode_id: parent_episode_id
|
|
439
|
+
)
|
|
440
|
+
return nil unless episode
|
|
441
|
+
|
|
442
|
+
persist_episode_state!(session)
|
|
443
|
+
episode.id
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def persist_episode_state!(session)
|
|
447
|
+
return unless @memory.respond_to?(:episodic)
|
|
448
|
+
|
|
449
|
+
episodic = @memory.episodic
|
|
450
|
+
return unless episodic
|
|
451
|
+
|
|
452
|
+
session.metadata[:episodes] = episodic.serialize
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Lifecycle
|
|
5
|
+
# State machine for agent lifecycle. Invalid transitions raise InvalidStateError.
|
|
6
|
+
#
|
|
7
|
+
# The :complete -> :running transition is intentional — it allows multi-turn
|
|
8
|
+
# conversations via #chat, where each turn goes through the full lifecycle.
|
|
9
|
+
module States
|
|
10
|
+
STATES = %i[
|
|
11
|
+
uninitialized
|
|
12
|
+
ready
|
|
13
|
+
running
|
|
14
|
+
waiting_for_tool
|
|
15
|
+
processing
|
|
16
|
+
suspended
|
|
17
|
+
finishing
|
|
18
|
+
complete
|
|
19
|
+
error
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
VALID_TRANSITIONS = {
|
|
23
|
+
uninitialized: [:ready],
|
|
24
|
+
ready: [:running],
|
|
25
|
+
running: [:waiting_for_tool, :finishing, :suspended, :error],
|
|
26
|
+
waiting_for_tool: [:processing, :suspended, :error],
|
|
27
|
+
processing: [:running, :finishing, :suspended, :error],
|
|
28
|
+
suspended: [:running],
|
|
29
|
+
finishing: [:complete, :error],
|
|
30
|
+
complete: [:running],
|
|
31
|
+
error: [],
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
def self.valid_transition?(from, to)
|
|
35
|
+
VALID_TRANSITIONS.fetch(from, []).include?(to)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.validate_transition!(from, to)
|
|
39
|
+
return if valid_transition?(from, to)
|
|
40
|
+
|
|
41
|
+
raise Spurline::InvalidStateError,
|
|
42
|
+
"Invalid state transition: #{from} -> #{to}. " \
|
|
43
|
+
"Valid transitions from #{from}: #{VALID_TRANSITIONS[from].inspect}."
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Lifecycle
|
|
5
|
+
# Immutable value object marking where suspension can happen.
|
|
6
|
+
# Types: :after_tool_result, :before_llm_call
|
|
7
|
+
class SuspensionBoundary
|
|
8
|
+
TYPES = %i[after_tool_result before_llm_call].freeze
|
|
9
|
+
|
|
10
|
+
attr_reader :type, :context
|
|
11
|
+
|
|
12
|
+
def initialize(type:, context: {})
|
|
13
|
+
normalized_type = type.to_sym
|
|
14
|
+
unless TYPES.include?(normalized_type)
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"Invalid suspension boundary type #{type.inspect}. " \
|
|
17
|
+
"Expected one of #{TYPES.inspect}."
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
unless context.is_a?(Hash)
|
|
21
|
+
raise ArgumentError, "Suspension boundary context must be a Hash"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@type = normalized_type
|
|
25
|
+
@context = context.dup.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Internal flow control signal — NOT an error class.
|
|
31
|
+
# Raised by Runner when suspension_check returns :suspend.
|
|
32
|
+
# Caught by Agent to trigger session suspension.
|
|
33
|
+
class SuspensionSignal < StandardError
|
|
34
|
+
attr_reader :checkpoint
|
|
35
|
+
|
|
36
|
+
def initialize(checkpoint:)
|
|
37
|
+
@checkpoint = checkpoint
|
|
38
|
+
super("Agent suspended at boundary")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Callable interface for suspension decisions.
|
|
43
|
+
# Receives a SuspensionBoundary, returns :continue or :suspend.
|
|
44
|
+
class SuspensionCheck
|
|
45
|
+
def initialize(&block)
|
|
46
|
+
@check = block || ->(_boundary) { :continue }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def call(boundary)
|
|
50
|
+
result = @check.call(boundary)
|
|
51
|
+
unless %i[continue suspend].include?(result)
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"SuspensionCheck must return :continue or :suspend, got #{result.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Factory: always continue (default)
|
|
60
|
+
def self.none
|
|
61
|
+
new { :continue }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Factory: suspend after N tool calls
|
|
65
|
+
def self.after_tool_calls(n)
|
|
66
|
+
unless n.is_a?(Integer) && n.positive?
|
|
67
|
+
raise ArgumentError, "n must be a positive Integer"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
count = 0
|
|
71
|
+
new do |boundary|
|
|
72
|
+
if boundary.type == :after_tool_result
|
|
73
|
+
count += 1
|
|
74
|
+
count >= n ? :suspend : :continue
|
|
75
|
+
else
|
|
76
|
+
:continue
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3664 | 8:45 PM | 🔵 | Spurline Milestone 1 implementation status audit completed | ~598 |
|
|
11
|
+
| #3631 | 6:00 PM | ⚖️ | Long-term memory architecture with pgvector and OpenAI embeddings | ~501 |
|
|
12
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "date"
|
|
3
|
+
|
|
4
|
+
module Spurline
|
|
5
|
+
module Memory
|
|
6
|
+
# Assembles context for the LLM from persona, memory, and user input.
|
|
7
|
+
# Returns an ordered array of Content objects — never raw strings.
|
|
8
|
+
#
|
|
9
|
+
# Assembly order:
|
|
10
|
+
# 1. System prompt (trust: :system)
|
|
11
|
+
# 2. Persona supplements (trust: :system, optional)
|
|
12
|
+
# 3. Recalled long-term memories (trust: :operator, optional)
|
|
13
|
+
# 4. Recent conversation history (trust: inherited from original)
|
|
14
|
+
# 5. Current user input (trust: :user)
|
|
15
|
+
class ContextAssembler
|
|
16
|
+
def assemble(input:, memory:, persona:, session: nil, agent_context: nil)
|
|
17
|
+
contents = []
|
|
18
|
+
|
|
19
|
+
# 1. System prompt (trust: :system)
|
|
20
|
+
contents << persona.render if persona
|
|
21
|
+
|
|
22
|
+
# 2. Persona injection supplements (trust: :system)
|
|
23
|
+
if persona
|
|
24
|
+
inject_persona_supplements!(
|
|
25
|
+
contents,
|
|
26
|
+
persona,
|
|
27
|
+
session: session,
|
|
28
|
+
agent_context: agent_context
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# 3. Recalled long-term memories (trust: :operator)
|
|
33
|
+
if memory.respond_to?(:recall)
|
|
34
|
+
recalled = memory.recall(query: extract_query_text(input), limit: 5)
|
|
35
|
+
contents.concat(recalled) if recalled.any?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# 4. Recent conversation history (trust: inherited from original)
|
|
39
|
+
memory.recent_turns.each do |turn|
|
|
40
|
+
contents << turn.input if turn.input.is_a?(Security::Content)
|
|
41
|
+
contents << turn.output if turn.output.is_a?(Security::Content)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 5. Current user input (trust: :user)
|
|
45
|
+
contents << input if input.is_a?(Security::Content)
|
|
46
|
+
|
|
47
|
+
contents.compact
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Estimates token count for assembled context. Rough approximation
|
|
51
|
+
# at ~4 characters per token. Used for trimming decisions.
|
|
52
|
+
def estimate_tokens(contents)
|
|
53
|
+
contents.sum { |c| (c.text.length / 4.0).ceil }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def inject_persona_supplements!(contents, persona, session:, agent_context:)
|
|
59
|
+
if persona.inject_date?
|
|
60
|
+
contents << Security::Gates::SystemPrompt.wrap(
|
|
61
|
+
"Current date: #{Date.today.iso8601}",
|
|
62
|
+
persona: "injection:date"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if persona.inject_user_context? && session&.user
|
|
67
|
+
contents << Security::Gates::SystemPrompt.wrap(
|
|
68
|
+
"Current user: #{session.user}",
|
|
69
|
+
persona: "injection:user_context"
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if persona.inject_agent_context? && agent_context
|
|
74
|
+
contents << Security::Gates::SystemPrompt.wrap(
|
|
75
|
+
build_agent_context_text(agent_context),
|
|
76
|
+
persona: "injection:agent_context"
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_agent_context_text(context)
|
|
82
|
+
parts = []
|
|
83
|
+
parts << "Agent: #{context[:class_name]}" if context[:class_name]
|
|
84
|
+
if context[:tool_names]&.any?
|
|
85
|
+
parts << "Available tools: #{context[:tool_names].join(', ')}"
|
|
86
|
+
end
|
|
87
|
+
parts.join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def extract_query_text(input)
|
|
91
|
+
case input
|
|
92
|
+
when Security::Content
|
|
93
|
+
input.text
|
|
94
|
+
else
|
|
95
|
+
input.to_s
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
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
|
+
| #3664 | 8:45 PM | 🔵 | Spurline Milestone 1 implementation status audit completed | ~598 |
|
|
11
|
+
</claude-mem-context>
|