spurline-test 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 +160 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 62dc04a762016630b7ff64e1533f36f053f9a71469e25fe37b35c202394c47c9
|
|
4
|
+
data.tar.gz: 6421a4919f0727e7527beadae4ef1867adaca01657b97c91522c42999f401031
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 60b85a8c1ecf14f8631cde017297198c0492ba1282275f0fb3f2bc8410e2aac3a02c0d38317e58cee57c9e8c8add5c079db0f2beb30bb59a04eb431ba0bb195a
|
|
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
|