spurline-core 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. metadata +333 -0
@@ -0,0 +1,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>