spurline-docs 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 +160 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d01e41a525f02722170194e4cbe75d41ffa59143cc22b04c6530f8e44bfddfab
4
+ data.tar.gz: 6421a4919f0727e7527beadae4ef1867adaca01657b97c91522c42999f401031
5
+ SHA512:
6
+ metadata.gz: 5c063bca9b9cd710dbf1497505d4db261ee03fba423d98f24817d064eec2e696aae0c319f22a8e4f4982ebe2f64b10f4559873c5bb58d180081c062a61731746
7
+ data.tar.gz: 290f4d0784c2d8e1bdb12e2cdcf77b5a6e50a1c3afe83e14e5d770d5a6697e1607974b528714c9636760d6207e9cc2755c7c367ed52b33e4ef432a2219b4bd90
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ # Abstract base class for LLM adapters. Adapters translate between
6
+ # Spurline's internal representation and a specific LLM API.
7
+ #
8
+ # The primary interface is #stream (ADR-001). The scheduler parameter
9
+ # is the async seam (ADR-002).
10
+ class Base
11
+ # ASYNC-READY: scheduler param is the async entry point
12
+ def stream(messages:, system:, tools:, config:, scheduler: Scheduler::Sync.new, &chunk_handler)
13
+ raise NotImplementedError, "#{self.class.name} must implement #stream"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+
4
+ module Spurline
5
+ module Adapters
6
+ # Claude adapter using the official anthropic gem.
7
+ # Translates between Spurline's internal representation and the Claude API.
8
+ #
9
+ # This adapter streams responses and converts API events into
10
+ # Spurline::Streaming::Chunk objects.
11
+ class Claude < Base
12
+ DEFAULT_MODEL = "claude-sonnet-4-20250514"
13
+ DEFAULT_MAX_TOKENS = 4096
14
+
15
+ def initialize(api_key: nil, model: nil, max_tokens: nil)
16
+ @api_key = resolve_api_key(api_key)
17
+ @model = model || DEFAULT_MODEL
18
+ @max_tokens = max_tokens || DEFAULT_MAX_TOKENS
19
+ end
20
+
21
+ # ASYNC-READY: scheduler param is the async entry point
22
+ def stream(messages:, system: nil, tools: [], config: {}, scheduler: Scheduler::Sync.new, &chunk_handler)
23
+ model = config[:model] || @model
24
+ max_tokens = config[:max_tokens] || @max_tokens
25
+ turn = config[:turn] || 1
26
+ pending_tool_input_snapshots = []
27
+
28
+ scheduler.run do
29
+ client = build_client
30
+
31
+ params = {
32
+ model: model,
33
+ max_tokens: max_tokens,
34
+ messages: format_messages(messages),
35
+ }
36
+
37
+ params[:system] = system if system && !system.empty?
38
+ params[:tools] = format_tools(tools) if tools && !tools.empty?
39
+ params[:tool_choice] = config[:tool_choice] if config[:tool_choice]
40
+
41
+ client.messages.stream(**params).each do |event|
42
+ handle_stream_event(
43
+ event,
44
+ turn: turn,
45
+ pending_tool_input_snapshots: pending_tool_input_snapshots,
46
+ &chunk_handler
47
+ )
48
+ end
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_api_key(explicit_key)
55
+ candidates = [
56
+ explicit_key,
57
+ ENV.fetch("ANTHROPIC_API_KEY", nil),
58
+ Spurline.credentials["anthropic_api_key"],
59
+ ]
60
+ key = candidates.find { |value| present_string?(value) }
61
+ return key if key
62
+
63
+ raise Spurline::ConfigurationError,
64
+ "Missing Anthropic API key for adapter :claude. " \
65
+ "Set ANTHROPIC_API_KEY, add anthropic_api_key to Spurline.credentials, " \
66
+ "or pass api_key:."
67
+ end
68
+
69
+ def present_string?(value)
70
+ return false if value.nil?
71
+ return !value.strip.empty? if value.respond_to?(:strip)
72
+
73
+ true
74
+ end
75
+
76
+ def build_client
77
+ require "anthropic"
78
+ Anthropic::Client.new(api_key: @api_key)
79
+ rescue LoadError
80
+ raise Spurline::ConfigurationError,
81
+ "The 'anthropic' gem is required for adapter :claude. " \
82
+ "Add `gem \"anthropic\"` to your Gemfile."
83
+ end
84
+
85
+ def format_messages(messages)
86
+ messages.map do |msg|
87
+ content = msg[:content]
88
+
89
+ # Content blocks (tool_use, tool_result) pass through as-is
90
+ formatted_content = if content.is_a?(Array)
91
+ content
92
+ else
93
+ content.to_s
94
+ end
95
+
96
+ {
97
+ role: msg[:role] || "user",
98
+ content: formatted_content,
99
+ }
100
+ end
101
+ end
102
+
103
+ def format_tools(tools)
104
+ tools.map do |tool|
105
+ {
106
+ name: tool[:name].to_s,
107
+ description: tool[:description].to_s,
108
+ input_schema: tool[:input_schema] || {},
109
+ }
110
+ end
111
+ end
112
+
113
+ def handle_stream_event(event, turn:, pending_tool_input_snapshots:, &chunk_handler)
114
+ case event
115
+ when Anthropic::Streaming::TextEvent
116
+ chunk_handler.call(
117
+ Streaming::Chunk.new(
118
+ type: :text,
119
+ text: event.text,
120
+ turn: turn,
121
+ )
122
+ )
123
+ when Anthropic::Streaming::InputJsonEvent
124
+ snapshot = event.respond_to?(:snapshot) ? event.snapshot : nil
125
+ normalized = normalize_tool_arguments(snapshot)
126
+ pending_tool_input_snapshots << normalized unless normalized.empty?
127
+ when Anthropic::Streaming::ContentBlockStopEvent
128
+ content_block = event.content_block
129
+ return unless content_block && content_block.type.to_s == "tool_use"
130
+
131
+ tool_name = content_block.name.to_s
132
+ arguments = normalize_tool_arguments(content_block.input)
133
+ if arguments.empty? && pending_tool_input_snapshots.any?
134
+ arguments = pending_tool_input_snapshots.last
135
+ end
136
+ pending_tool_input_snapshots.clear
137
+
138
+ chunk_handler.call(
139
+ Streaming::Chunk.new(
140
+ type: :tool_start,
141
+ turn: turn,
142
+ metadata: {
143
+ tool_name: tool_name,
144
+ tool_use_id: content_block.id,
145
+ tool_call: {
146
+ name: tool_name,
147
+ arguments: arguments,
148
+ },
149
+ }
150
+ )
151
+ )
152
+ when Anthropic::Streaming::MessageStopEvent
153
+ chunk_handler.call(
154
+ Streaming::Chunk.new(
155
+ type: :done,
156
+ turn: turn,
157
+ metadata: { stop_reason: event.message.stop_reason.to_s }
158
+ )
159
+ )
160
+ end
161
+ end
162
+
163
+ def normalize_tool_arguments(raw_input)
164
+ case raw_input
165
+ when nil
166
+ {}
167
+ when Hash
168
+ raw_input
169
+ when String
170
+ parse_json_object(raw_input)
171
+ else
172
+ from_hash_like = extract_hash_like(raw_input)
173
+ return from_hash_like unless from_hash_like.empty?
174
+
175
+ json_candidate = raw_input.respond_to?(:to_json) ? raw_input.to_json : nil
176
+ parse_json_object(json_candidate)
177
+ end
178
+ rescue StandardError
179
+ {}
180
+ end
181
+
182
+ def extract_hash_like(value)
183
+ if value.respond_to?(:to_h)
184
+ converted = value.to_h
185
+ return converted if converted.is_a?(Hash)
186
+ end
187
+
188
+ if value.respond_to?(:to_hash)
189
+ converted = value.to_hash
190
+ return converted if converted.is_a?(Hash)
191
+ end
192
+
193
+ {}
194
+ rescue StandardError
195
+ {}
196
+ end
197
+
198
+ def parse_json_object(raw_json)
199
+ return {} unless raw_json.is_a?(String) && !raw_json.strip.empty?
200
+
201
+ parsed = JSON.parse(raw_json)
202
+ parsed.is_a?(Hash) ? parsed : {}
203
+ rescue JSON::ParserError
204
+ {}
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Spurline
6
+ module Adapters
7
+ # OpenAI adapter using the ruby-openai gem.
8
+ # Translates between Spurline's internal representation and the OpenAI API.
9
+ class OpenAI < Base
10
+ DEFAULT_MODEL = "gpt-4o"
11
+ DEFAULT_MAX_TOKENS = 4096
12
+
13
+ STOP_REASON_MAP = {
14
+ "stop" => "end_turn",
15
+ "tool_calls" => "tool_use",
16
+ "length" => "max_tokens",
17
+ "content_filter" => "content_filter",
18
+ }.freeze
19
+
20
+ def initialize(api_key: nil, model: nil, max_tokens: nil)
21
+ @api_key = resolve_api_key(api_key)
22
+ @model = model || DEFAULT_MODEL
23
+ @max_tokens = max_tokens || DEFAULT_MAX_TOKENS
24
+ end
25
+
26
+ # ASYNC-READY: scheduler param is the async entry point
27
+ def stream(messages:, system: nil, tools: [], config: {}, scheduler: Scheduler::Sync.new, &chunk_handler)
28
+ model = config[:model] || @model
29
+ max_tokens = config[:max_tokens] || @max_tokens
30
+ turn = config[:turn] || 1
31
+ pending_tool_calls = {}
32
+
33
+ scheduler.run do
34
+ client = build_client
35
+
36
+ params = {
37
+ model: model,
38
+ max_tokens: max_tokens,
39
+ messages: format_messages(messages, system: system),
40
+ stream: proc do |chunk|
41
+ handle_stream_chunk(
42
+ chunk,
43
+ turn: turn,
44
+ pending_tool_calls: pending_tool_calls,
45
+ &chunk_handler
46
+ )
47
+ end,
48
+ }
49
+
50
+ params[:tools] = format_tools(tools) if tools && !tools.empty?
51
+
52
+ client.chat(parameters: params)
53
+ flush_pending_tool_calls!(pending_tool_calls, turn: turn, &chunk_handler)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_api_key(explicit_key)
60
+ candidates = [
61
+ explicit_key,
62
+ ENV.fetch("OPENAI_API_KEY", nil),
63
+ Spurline.credentials["openai_api_key"],
64
+ ]
65
+ key = candidates.find { |value| present_string?(value) }
66
+ return key if key
67
+
68
+ raise Spurline::ConfigurationError,
69
+ "Missing OpenAI API key for adapter :openai. " \
70
+ "Set OPENAI_API_KEY, add openai_api_key to Spurline.credentials, " \
71
+ "or pass api_key:."
72
+ end
73
+
74
+ def present_string?(value)
75
+ return false if value.nil?
76
+ return !value.strip.empty? if value.respond_to?(:strip)
77
+
78
+ true
79
+ end
80
+
81
+ def build_client
82
+ require "openai"
83
+ ::OpenAI::Client.new(access_token: @api_key)
84
+ rescue LoadError
85
+ raise Spurline::ConfigurationError,
86
+ "The 'ruby-openai' gem is required for adapter :openai. " \
87
+ "Add `gem \"ruby-openai\"` to your Gemfile."
88
+ end
89
+
90
+ # OpenAI expects the system prompt as a message in the message array.
91
+ def format_messages(messages, system: nil)
92
+ formatted = []
93
+ formatted << { role: "system", content: system } if present_string?(system)
94
+
95
+ messages.each do |message|
96
+ formatted << {
97
+ role: message[:role] || "user",
98
+ content: message[:content].to_s,
99
+ }
100
+ end
101
+
102
+ formatted
103
+ end
104
+
105
+ # OpenAI wraps tools in { type: "function", function: { ... } }.
106
+ def format_tools(tools)
107
+ tools.map do |tool|
108
+ {
109
+ type: "function",
110
+ function: {
111
+ name: tool[:name].to_s,
112
+ description: tool[:description].to_s,
113
+ parameters: tool[:input_schema] || {},
114
+ },
115
+ }
116
+ end
117
+ end
118
+
119
+ def handle_stream_chunk(chunk, turn:, pending_tool_calls:, &chunk_handler)
120
+ choice = first_choice(chunk)
121
+ return unless choice
122
+
123
+ delta = read_key(choice, "delta") || {}
124
+ finish_reason = read_key(choice, "finish_reason")
125
+
126
+ content = read_key(delta, "content")
127
+ if content
128
+ chunk_handler.call(
129
+ Streaming::Chunk.new(type: :text, text: content, turn: turn)
130
+ )
131
+ end
132
+
133
+ tool_calls = read_key(delta, "tool_calls")
134
+ accumulate_tool_call_deltas!(tool_calls, pending_tool_calls) if tool_calls
135
+
136
+ return unless finish_reason
137
+
138
+ chunk_handler.call(
139
+ Streaming::Chunk.new(
140
+ type: :done,
141
+ turn: turn,
142
+ metadata: { stop_reason: STOP_REASON_MAP[finish_reason] || finish_reason }
143
+ )
144
+ )
145
+ end
146
+
147
+ def first_choice(chunk)
148
+ choices = read_key(chunk, "choices")
149
+ return nil unless choices.is_a?(Array)
150
+
151
+ choices.first
152
+ end
153
+
154
+ def read_key(hash, key)
155
+ return nil unless hash.respond_to?(:[])
156
+
157
+ hash[key] || hash[key.to_sym]
158
+ end
159
+
160
+ def accumulate_tool_call_deltas!(tool_call_deltas, pending_tool_calls)
161
+ return unless tool_call_deltas.is_a?(Array)
162
+
163
+ tool_call_deltas.each do |tool_call_delta|
164
+ index = read_key(tool_call_delta, "index") || 0
165
+ pending_tool_calls[index] ||= { id: nil, name: "", arguments: "" }
166
+ tool_call = pending_tool_calls[index]
167
+
168
+ tool_call[:id] = read_key(tool_call_delta, "id") || tool_call[:id]
169
+ function_data = read_key(tool_call_delta, "function") || {}
170
+
171
+ name = read_key(function_data, "name")
172
+ tool_call[:name] = name if name
173
+
174
+ arguments_delta = read_key(function_data, "arguments")
175
+ tool_call[:arguments] += arguments_delta.to_s if arguments_delta
176
+ end
177
+ end
178
+
179
+ def flush_pending_tool_calls!(pending_tool_calls, turn:, &chunk_handler)
180
+ pending_tool_calls.keys.sort.each do |index|
181
+ tool_call = pending_tool_calls[index]
182
+ next if tool_call[:name].empty?
183
+
184
+ chunk_handler.call(
185
+ Streaming::Chunk.new(
186
+ type: :tool_start,
187
+ turn: turn,
188
+ metadata: {
189
+ tool_name: tool_call[:name],
190
+ tool_use_id: tool_call[:id],
191
+ tool_call: {
192
+ name: tool_call[:name],
193
+ arguments: parse_tool_arguments(tool_call[:arguments]),
194
+ },
195
+ }
196
+ )
197
+ )
198
+ end
199
+
200
+ pending_tool_calls.clear
201
+ end
202
+
203
+ def parse_tool_arguments(raw_json)
204
+ return {} unless raw_json.is_a?(String) && !raw_json.strip.empty?
205
+
206
+ parsed = JSON.parse(raw_json)
207
+ parsed.is_a?(Hash) ? parsed : {}
208
+ rescue JSON::ParserError
209
+ {}
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ # Registry of available LLM adapters. Maps symbolic names to adapter classes.
6
+ class Registry
7
+ def initialize
8
+ @adapters = {}
9
+ end
10
+
11
+ def register(name, adapter_class)
12
+ @adapters[name.to_sym] = adapter_class
13
+ end
14
+
15
+ def resolve(name)
16
+ name = name.to_sym
17
+ @adapters.fetch(name) do
18
+ raise Spurline::AdapterNotFoundError,
19
+ "Adapter '#{name}' is not registered. Available adapters: " \
20
+ "#{@adapters.keys.map(&:inspect).join(", ")}."
21
+ end
22
+ end
23
+
24
+ def registered?(name)
25
+ @adapters.key?(name.to_sym)
26
+ end
27
+
28
+ def names
29
+ @adapters.keys
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ module Scheduler
6
+ # Abstract scheduler interface. The scheduler parameter is the async seam (ADR-002).
7
+ # v1 ships only Sync. A future async scheduler will implement the same interface.
8
+ class Base
9
+ def run(&block)
10
+ raise NotImplementedError, "#{self.class.name} must implement #run"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ module Scheduler
6
+ # Synchronous no-op scheduler. Simply yields the block.
7
+ # This is the v1 default — the async seam (ADR-002).
8
+ class Sync < Base
9
+ def run(&block)
10
+ yield
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spurline
4
+ module Adapters
5
+ # Test adapter that plays back canned streaming responses.
6
+ # Ships with the framework — available in production code for testing and demos.
7
+ #
8
+ # Usage:
9
+ # adapter = StubAdapter.new(responses: [
10
+ # stub_text("Here is what I found..."),
11
+ # stub_tool_call(:web_search, query: "test"),
12
+ # stub_text("Based on my research...")
13
+ # ])
14
+ class StubAdapter < Base
15
+ attr_reader :calls
16
+
17
+ def initialize(responses: [])
18
+ @responses = responses
19
+ @response_index = 0
20
+ @calls = []
21
+ end
22
+
23
+ # ASYNC-READY: scheduler param is the async entry point
24
+ def stream(messages:, system: nil, tools: [], config: {}, scheduler: Scheduler::Sync.new, &chunk_handler)
25
+ @calls << { messages: messages, system: system, tools: tools, config: config }
26
+
27
+ response = next_response!
28
+
29
+ response[:chunks].each do |chunk|
30
+ chunk_handler.call(chunk)
31
+ end
32
+
33
+ response
34
+ end
35
+
36
+ def call_count
37
+ @calls.length
38
+ end
39
+
40
+ private
41
+
42
+ def next_response!
43
+ if @response_index >= @responses.length
44
+ raise "StubAdapter exhausted: #{@responses.length} responses configured, " \
45
+ "but call ##{@response_index + 1} was made."
46
+ end
47
+
48
+ response = @responses[@response_index]
49
+ @response_index += 1
50
+ response
51
+ end
52
+ end
53
+ end
54
+ end