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