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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# Accumulates streaming chunks to detect tool call boundaries.
|
|
6
|
+
# Text chunks are yielded immediately to the caller.
|
|
7
|
+
# Tool calls are only dispatched when the full argument payload has arrived.
|
|
8
|
+
#
|
|
9
|
+
# Handles edge cases:
|
|
10
|
+
# - Multiple tool calls in a single response
|
|
11
|
+
# - Partial JSON arguments across chunks
|
|
12
|
+
# - Mixed text and tool call responses
|
|
13
|
+
class Buffer
|
|
14
|
+
attr_reader :chunks
|
|
15
|
+
|
|
16
|
+
def initialize
|
|
17
|
+
@chunks = []
|
|
18
|
+
@stop_reason = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def <<(chunk)
|
|
22
|
+
@chunks << chunk
|
|
23
|
+
@stop_reason = chunk.metadata[:stop_reason] if chunk.done?
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def complete?
|
|
28
|
+
@chunks.any?(&:done?)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def tool_call?
|
|
32
|
+
@stop_reason == "tool_use"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def text_chunks
|
|
36
|
+
@chunks.select(&:text?)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def full_text
|
|
40
|
+
text_chunks.map(&:text).join
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Returns all tool call data from metadata.
|
|
44
|
+
# Supports multiple tool calls in a single response.
|
|
45
|
+
def tool_calls
|
|
46
|
+
calls = @chunks
|
|
47
|
+
.select { |c| c.metadata[:tool_call] }
|
|
48
|
+
.map { |c| c.metadata[:tool_call] }
|
|
49
|
+
|
|
50
|
+
# Deduplicate by name+arguments (guards against duplicate chunk delivery)
|
|
51
|
+
calls.uniq { |c| [c[:name], c[:arguments]] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def tool_call_count
|
|
55
|
+
tool_calls.length
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The stop reason from the done chunk.
|
|
59
|
+
def stop_reason
|
|
60
|
+
@stop_reason
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear!
|
|
64
|
+
@chunks = []
|
|
65
|
+
@stop_reason = nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def size
|
|
69
|
+
@chunks.length
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def empty?
|
|
73
|
+
@chunks.empty?
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# A typed chunk of streaming output. Never a raw string.
|
|
6
|
+
#
|
|
7
|
+
# Types:
|
|
8
|
+
# :text — text content from the LLM
|
|
9
|
+
# :tool_start — a tool execution is beginning
|
|
10
|
+
# :tool_end — a tool execution has completed
|
|
11
|
+
# :done — the stream is complete
|
|
12
|
+
class Chunk
|
|
13
|
+
TYPES = %i[text tool_start tool_end done].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :type, :text, :turn, :session_id, :metadata
|
|
16
|
+
|
|
17
|
+
def initialize(type:, text: nil, turn: nil, session_id: nil, metadata: {})
|
|
18
|
+
validate_type!(type)
|
|
19
|
+
|
|
20
|
+
@type = type
|
|
21
|
+
@text = text&.dup&.freeze
|
|
22
|
+
@turn = turn
|
|
23
|
+
@session_id = session_id
|
|
24
|
+
@metadata = metadata.freeze
|
|
25
|
+
freeze
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def text?
|
|
29
|
+
type == :text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tool_start?
|
|
33
|
+
type == :tool_start
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_end?
|
|
37
|
+
type == :tool_end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def done?
|
|
41
|
+
type == :done
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def inspect
|
|
45
|
+
parts = ["type=#{type}"]
|
|
46
|
+
parts << "text=#{text[0..30].inspect}#{text.length > 30 ? "..." : ""}" if text
|
|
47
|
+
parts << "turn=#{turn}" if turn
|
|
48
|
+
"#<Spurline::Streaming::Chunk #{parts.join(" ")}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def validate_type!(type)
|
|
54
|
+
return if TYPES.include?(type)
|
|
55
|
+
|
|
56
|
+
raise Spurline::ConfigurationError,
|
|
57
|
+
"Invalid chunk type: #{type.inspect}. " \
|
|
58
|
+
"Must be one of: #{TYPES.map(&:inspect).join(", ")}."
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
module Streaming
|
|
5
|
+
# Wraps a block-based streaming interface into a Ruby Enumerator.
|
|
6
|
+
# This allows both block and enumerator usage patterns (ADR-001):
|
|
7
|
+
#
|
|
8
|
+
# agent.run("hello") { |chunk| print chunk.text }
|
|
9
|
+
# agent.run("hello").each { |chunk| print chunk.text }
|
|
10
|
+
#
|
|
11
|
+
class StreamEnumerator
|
|
12
|
+
include Enumerable
|
|
13
|
+
|
|
14
|
+
def initialize(&producer)
|
|
15
|
+
@producer = producer
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def each(&consumer)
|
|
19
|
+
if consumer
|
|
20
|
+
@producer.call(consumer)
|
|
21
|
+
else
|
|
22
|
+
::Enumerator.new do |yielder|
|
|
23
|
+
@producer.call(proc { |chunk| yielder << chunk })
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
# Test helpers for Spurline agents. Require in your spec_helper:
|
|
5
|
+
#
|
|
6
|
+
# require "spurline/testing"
|
|
7
|
+
#
|
|
8
|
+
# Then include in your specs:
|
|
9
|
+
#
|
|
10
|
+
# include Spurline::Testing
|
|
11
|
+
#
|
|
12
|
+
# Or let the auto-configuration handle it (included globally if RSpec is loaded).
|
|
13
|
+
module Testing
|
|
14
|
+
TOOL_SOURCE_PATTERN = /source=["']tool:(?<tool_name>[^"']+)["']/.freeze
|
|
15
|
+
|
|
16
|
+
# Creates a stub text response that streams as chunks.
|
|
17
|
+
def stub_text(text, turn: 1)
|
|
18
|
+
chunks = text.chars.each_slice(5).map do |chars|
|
|
19
|
+
Spurline::Streaming::Chunk.new(
|
|
20
|
+
type: :text,
|
|
21
|
+
text: chars.join,
|
|
22
|
+
turn: turn
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
chunks << Spurline::Streaming::Chunk.new(
|
|
27
|
+
type: :done,
|
|
28
|
+
turn: turn,
|
|
29
|
+
metadata: { stop_reason: "end_turn" }
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
{ type: :text, text: text, chunks: chunks }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates a stub tool call response.
|
|
36
|
+
def stub_tool_call(tool_name, turn: 1, **arguments)
|
|
37
|
+
tool_call_data = { name: tool_name.to_s, arguments: arguments }
|
|
38
|
+
|
|
39
|
+
chunks = [
|
|
40
|
+
Spurline::Streaming::Chunk.new(
|
|
41
|
+
type: :tool_start,
|
|
42
|
+
turn: turn,
|
|
43
|
+
metadata: { tool_name: tool_name.to_s, arguments: arguments }
|
|
44
|
+
),
|
|
45
|
+
Spurline::Streaming::Chunk.new(
|
|
46
|
+
type: :done,
|
|
47
|
+
turn: turn,
|
|
48
|
+
metadata: {
|
|
49
|
+
stop_reason: "tool_use",
|
|
50
|
+
tool_call: tool_call_data,
|
|
51
|
+
}
|
|
52
|
+
),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
{ type: :tool_call, tool_call: tool_call_data, chunks: chunks }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Asserts that a tool was called, optionally with matching arguments.
|
|
59
|
+
#
|
|
60
|
+
# Sources checked in order:
|
|
61
|
+
# 1) audit_log tool_call events
|
|
62
|
+
# 2) session turn tool_calls
|
|
63
|
+
# 3) StubAdapter call history (tool presence only)
|
|
64
|
+
#
|
|
65
|
+
# @param tool_name [Symbol, String]
|
|
66
|
+
# @param with [Hash] expected argument subset
|
|
67
|
+
# @param agent [Spurline::Agent, nil]
|
|
68
|
+
# @param adapter [Spurline::Adapters::StubAdapter, nil]
|
|
69
|
+
# @param audit_log [Spurline::Audit::Log, nil]
|
|
70
|
+
# @param session [Spurline::Session::Session, nil]
|
|
71
|
+
# @return [true]
|
|
72
|
+
def assert_tool_called(tool_name, with: {}, agent: nil, adapter: nil, audit_log: nil, session: nil)
|
|
73
|
+
tool = tool_name.to_s
|
|
74
|
+
expected_arguments = deep_symbolize(with || {})
|
|
75
|
+
audit_entries, session_entries, adapter_instance = resolve_tool_call_sources(
|
|
76
|
+
agent: agent,
|
|
77
|
+
adapter: adapter,
|
|
78
|
+
audit_log: audit_log,
|
|
79
|
+
session: session
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
matched = match_tool_call(tool, expected_arguments, audit_entries, key: :tool) ||
|
|
83
|
+
match_tool_call(tool, expected_arguments, session_entries, key: :name)
|
|
84
|
+
return true if matched
|
|
85
|
+
|
|
86
|
+
if tool_called_in_history?(tool, audit_entries, key: :tool) ||
|
|
87
|
+
tool_called_in_history?(tool, session_entries, key: :name)
|
|
88
|
+
raise_expectation!(
|
|
89
|
+
"Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if tool_called_in_adapter_history?(adapter_instance, tool)
|
|
94
|
+
if expected_arguments.empty?
|
|
95
|
+
return true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
raise_expectation!(
|
|
99
|
+
"Tool '#{tool}' was detected in StubAdapter call history, but argument assertions " \
|
|
100
|
+
"require session or audit history. Pass `agent:`, `session:`, or `audit_log:`."
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise_expectation!(
|
|
105
|
+
"Expected tool '#{tool}' to be called#{format_expected_arguments(expected_arguments)}."
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Asserts that no injection detection error is raised while evaluating the block.
|
|
110
|
+
#
|
|
111
|
+
# @yield A call that runs through the context pipeline.
|
|
112
|
+
# @return [true]
|
|
113
|
+
def expect_no_injection
|
|
114
|
+
raise ArgumentError, "expect_no_injection requires a block" unless block_given?
|
|
115
|
+
|
|
116
|
+
yield
|
|
117
|
+
true
|
|
118
|
+
rescue *injection_error_classes => e
|
|
119
|
+
raise_expectation!(
|
|
120
|
+
"Expected no injection detection errors, but #{e.class.name} was raised: #{e.message}"
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Asserts that a Content object carries the expected trust level.
|
|
125
|
+
#
|
|
126
|
+
# @param content [Spurline::Security::Content]
|
|
127
|
+
# @param expected_trust [Symbol, String]
|
|
128
|
+
# @return [true]
|
|
129
|
+
def assert_trust_level(content, expected_trust)
|
|
130
|
+
unless content.is_a?(Spurline::Security::Content)
|
|
131
|
+
raise_expectation!(
|
|
132
|
+
"Expected Spurline::Security::Content, got #{content.class.name}."
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
expected = expected_trust.to_sym
|
|
137
|
+
actual = content.trust
|
|
138
|
+
return true if actual == expected
|
|
139
|
+
|
|
140
|
+
raise_expectation!("Expected trust level #{expected.inspect}, got #{actual.inspect}.")
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def resolve_tool_call_sources(agent:, adapter:, audit_log:, session:)
|
|
146
|
+
audit = audit_log
|
|
147
|
+
sess = session
|
|
148
|
+
adapter_instance = adapter
|
|
149
|
+
|
|
150
|
+
if agent
|
|
151
|
+
audit ||= agent.respond_to?(:audit_log) ? agent.audit_log : nil
|
|
152
|
+
sess ||= agent.respond_to?(:session) ? agent.session : nil
|
|
153
|
+
if adapter_instance.nil? && agent.instance_variable_defined?(:@adapter)
|
|
154
|
+
adapter_instance = agent.instance_variable_get(:@adapter)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
audit_entries = audit.respond_to?(:tool_calls) ? audit.tool_calls : []
|
|
159
|
+
session_entries = sess.respond_to?(:tool_calls) ? sess.tool_calls : []
|
|
160
|
+
|
|
161
|
+
[audit_entries, session_entries, adapter_instance]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def match_tool_call(tool, expected_arguments, entries, key:)
|
|
165
|
+
entries.find do |entry|
|
|
166
|
+
next false unless entry[key].to_s == tool
|
|
167
|
+
|
|
168
|
+
actual_arguments = deep_symbolize(entry[:arguments] || {})
|
|
169
|
+
hash_subset?(actual_arguments, expected_arguments)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def tool_called_in_history?(tool, entries, key:)
|
|
174
|
+
entries.any? { |entry| entry[key].to_s == tool }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def tool_called_in_adapter_history?(adapter, tool)
|
|
178
|
+
return false unless adapter.respond_to?(:calls)
|
|
179
|
+
|
|
180
|
+
adapter.calls.any? do |call|
|
|
181
|
+
messages = Array(call[:messages])
|
|
182
|
+
messages.any? do |message|
|
|
183
|
+
content = message[:content].to_s
|
|
184
|
+
match = TOOL_SOURCE_PATTERN.match(content)
|
|
185
|
+
match && match[:tool_name] == tool
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def hash_subset?(actual, expected)
|
|
191
|
+
return true if expected.empty?
|
|
192
|
+
return false unless actual.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
expected.all? do |key, value|
|
|
195
|
+
actual_value = actual[key]
|
|
196
|
+
if value.is_a?(Hash)
|
|
197
|
+
hash_subset?(actual_value, value)
|
|
198
|
+
else
|
|
199
|
+
actual_value == value
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def deep_symbolize(value)
|
|
205
|
+
case value
|
|
206
|
+
when Hash
|
|
207
|
+
value.each_with_object({}) do |(key, nested), hash|
|
|
208
|
+
hash[key.to_sym] = deep_symbolize(nested)
|
|
209
|
+
end
|
|
210
|
+
when Array
|
|
211
|
+
value.map { |item| deep_symbolize(item) }
|
|
212
|
+
else
|
|
213
|
+
value
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def format_expected_arguments(arguments)
|
|
218
|
+
return "" if arguments.empty?
|
|
219
|
+
|
|
220
|
+
" with arguments #{arguments.inspect}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def injection_error_classes
|
|
224
|
+
classes = []
|
|
225
|
+
classes << Spurline::InjectionAttemptError if defined?(Spurline::InjectionAttemptError)
|
|
226
|
+
classes << Spurline::InjectionDetectedError if defined?(Spurline::InjectionDetectedError)
|
|
227
|
+
classes
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def raise_expectation!(message)
|
|
231
|
+
if defined?(RSpec::Expectations::ExpectationNotMetError)
|
|
232
|
+
raise RSpec::Expectations::ExpectationNotMetError, message
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
raise RuntimeError, message
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Auto-include in RSpec if available
|
|
241
|
+
if defined?(RSpec)
|
|
242
|
+
RSpec.configure do |config|
|
|
243
|
+
config.include Spurline::Testing
|
|
244
|
+
end
|
|
245
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spurline
|
|
4
|
+
# A named group of tools with optional shared configuration.
|
|
5
|
+
# Toolkits own their tools — tools register through their toolkit,
|
|
6
|
+
# not independently. Tools remain leaf nodes (ADR-003).
|
|
7
|
+
#
|
|
8
|
+
# Three patterns for adding tools:
|
|
9
|
+
#
|
|
10
|
+
# # Pattern 1: External class (complex tools in their own file)
|
|
11
|
+
# class GitToolkit < Spurline::Toolkit
|
|
12
|
+
# toolkit_name :git
|
|
13
|
+
# tool GitCommit
|
|
14
|
+
# tool GitChangedFiles
|
|
15
|
+
# shared_config scoped: true
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# # Pattern 2: Inline definition (small tools)
|
|
19
|
+
# class CommsToolkit < Spurline::Toolkit
|
|
20
|
+
# toolkit_name :comms
|
|
21
|
+
# tool :send_message do
|
|
22
|
+
# description "Send a Teams message"
|
|
23
|
+
# parameters(type: "object", properties: { text: { type: "string" } })
|
|
24
|
+
# def call(text:)
|
|
25
|
+
# # implementation
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# # Pattern 3: Standalone tools (one-offs, no toolkit)
|
|
31
|
+
# # Register directly via tool_registry.register(:name, ToolClass)
|
|
32
|
+
#
|
|
33
|
+
class Toolkit
|
|
34
|
+
class << self
|
|
35
|
+
# Set or get the toolkit name. Inferred from class name if not set.
|
|
36
|
+
def toolkit_name(name = nil)
|
|
37
|
+
if name
|
|
38
|
+
@toolkit_name = name.to_sym
|
|
39
|
+
else
|
|
40
|
+
@toolkit_name || infer_name
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Register a tool in this toolkit.
|
|
45
|
+
#
|
|
46
|
+
# External class:
|
|
47
|
+
# tool GitCommit
|
|
48
|
+
#
|
|
49
|
+
# Inline definition:
|
|
50
|
+
# tool :send_message do
|
|
51
|
+
# description "..."
|
|
52
|
+
# parameters(...)
|
|
53
|
+
# def call(...); end
|
|
54
|
+
# end
|
|
55
|
+
def tool(tool_class_or_name = nil, &block)
|
|
56
|
+
@tool_entries ||= []
|
|
57
|
+
|
|
58
|
+
if block
|
|
59
|
+
name = tool_class_or_name.to_sym
|
|
60
|
+
klass = Class.new(Spurline::Tools::Base) do
|
|
61
|
+
tool_name name
|
|
62
|
+
end
|
|
63
|
+
klass.class_eval(&block)
|
|
64
|
+
@tool_entries << { name: name, tool_class: klass }
|
|
65
|
+
else
|
|
66
|
+
klass = tool_class_or_name
|
|
67
|
+
raise Spurline::ConfigurationError,
|
|
68
|
+
"Toolkit :#{toolkit_name} — `tool` expects a Tool class or a name with a block. " \
|
|
69
|
+
"Got: #{klass.inspect}" unless klass.is_a?(Class) && klass < Spurline::Tools::Base
|
|
70
|
+
|
|
71
|
+
@tool_entries << { name: klass.tool_name, tool_class: klass }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns tool name symbols for all tools in this toolkit.
|
|
76
|
+
def tools
|
|
77
|
+
(@tool_entries || []).map { |e| e[:name] }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns { name => ToolClass } for registration into a tool registry.
|
|
81
|
+
def tool_classes
|
|
82
|
+
(@tool_entries || []).each_with_object({}) { |e, h| h[e[:name]] = e[:tool_class] }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Shared configuration applied to every tool when this toolkit is
|
|
86
|
+
# included in an agent. Supports the same keys as per-tool config:
|
|
87
|
+
# requires_confirmation, scoped, timeout, denied, allowed_users.
|
|
88
|
+
def shared_config(**opts)
|
|
89
|
+
if opts.any?
|
|
90
|
+
@shared_config ||= {}
|
|
91
|
+
@shared_config.merge!(opts)
|
|
92
|
+
end
|
|
93
|
+
@shared_config&.dup || {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def infer_name
|
|
99
|
+
short = name&.split("::")&.last
|
|
100
|
+
return :unnamed unless short
|
|
101
|
+
|
|
102
|
+
short
|
|
103
|
+
.gsub(/Toolkit$/, "")
|
|
104
|
+
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
|
|
105
|
+
.downcase
|
|
106
|
+
.to_sym
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|