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.
- checksums.yaml +7 -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/analyzer.rb +71 -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/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/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/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/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/context_assembler.rb +100 -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/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/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/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/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 +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
|