spurline-core 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/LICENSE +21 -0
- data/README.md +177 -0
- data/exe/spur +6 -0
- data/lib/CLAUDE.md +11 -0
- data/lib/spurline/CLAUDE.md +16 -0
- data/lib/spurline/adapters/CLAUDE.md +12 -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/CLAUDE.md +12 -0
- data/lib/spurline/cartographer/analyzer.rb +71 -0
- data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +18 -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/CLAUDE.md +12 -0
- data/lib/spurline/memory/context_assembler.rb +100 -0
- data/lib/spurline/memory/embedder/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +11 -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/CLAUDE.md +12 -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/CLAUDE.md +12 -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 +333 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: cae2c379d4f118c54c94f1d9e37936a4a7801117209ae048a7021bfdc8d912d8
|
|
4
|
+
data.tar.gz: 79b2dddbd5eb414c2370694d25f8b97e2ff8e275981e74ac946aed82e0f484a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5554bc94698551f59c07621d68f34fb72438ee2c47cd44a365e5b0124a98265cef1edb0f387ef7d4099b6208e435fb1861711270de0c03828eec348e4e69ac84
|
|
7
|
+
data.tar.gz: 52577fcd9a698e86128dbefde465173a75acd2b7f5d776e90e5507138adb29a50f17e04b0521bdee1afd0c49b373141d4c22e82cf4571e3df412fa8ec6a4d4b2
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dylan Wilcox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Spurline
|
|
2
|
+
|
|
3
|
+
Spurline is a Ruby framework for building production-grade AI agents.
|
|
4
|
+
|
|
5
|
+
It is opinionated, streaming-first, and security-oriented by default:
|
|
6
|
+
|
|
7
|
+
- all content is trust-typed (`:system`, `:operator`, `:user`, `:external`)
|
|
8
|
+
- tool output is treated as untrusted external data
|
|
9
|
+
- prompt-injection and PII controls are built into the call pipeline
|
|
10
|
+
- sessions and audit logs are first-class framework concepts
|
|
11
|
+
|
|
12
|
+
## Status
|
|
13
|
+
|
|
14
|
+
Spurline is under active development. The core framework is usable and tested, with docs in `docs/guides`.
|
|
15
|
+
|
|
16
|
+
Recent hardening shipped:
|
|
17
|
+
|
|
18
|
+
- secret redaction for tool-call arguments in audit log, session turns, and stream metadata
|
|
19
|
+
- three-tier tool secret management (agent overrides, runtime vault, encrypted credentials, ENV fallback)
|
|
20
|
+
- structured replay audit events (`:llm_request`, `:llm_response`, `:tool_call`, `:tool_result`)
|
|
21
|
+
- configurable in-memory audit retention (`audit_max_entries`)
|
|
22
|
+
- stubbed integration coverage for guardrails, PII pipeline, SQLite round-trip/concurrency, memory overflow, streaming enumerator tool loops, and audit completeness
|
|
23
|
+
- adapter spurs via `Spurline::Spur.adapters` (for example `spurline-local` registering `:ollama`)
|
|
24
|
+
|
|
25
|
+
## Bundled Spurs
|
|
26
|
+
|
|
27
|
+
Spurline ships the framework as `spurline-core` and includes bundled reference spur gems under `spurs/`:
|
|
28
|
+
|
|
29
|
+
- `spurline-web-search`: web search tools powered by Brave Search (`spurs/spurline-web-search`)
|
|
30
|
+
- `spurline-deploy`: supervised deployment planning and execution with safety gates (`spurs/spurline-deploy`)
|
|
31
|
+
|
|
32
|
+
`spurline-deploy` provides four tools:
|
|
33
|
+
|
|
34
|
+
- `generate_deploy_plan` (idempotent by `repo_path`, `target`, `strategy`)
|
|
35
|
+
- `validate_deploy_prereqs` (scoped prereq validation)
|
|
36
|
+
- `execute_deploy_step` (always confirmation-gated, dry-run by default)
|
|
37
|
+
- `rollback_deploy` (confirmation-gated rollback with optional auto-detect target version)
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Ruby `>= 3.2`
|
|
42
|
+
- Bundler
|
|
43
|
+
|
|
44
|
+
## Install (Framework Development)
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone git@github.com:dawilco/spurline.git
|
|
48
|
+
cd spurline
|
|
49
|
+
bundle install
|
|
50
|
+
bundle exec rspec
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Quick Example
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
require "spurline"
|
|
57
|
+
require "spurline/testing"
|
|
58
|
+
include Spurline::Testing
|
|
59
|
+
|
|
60
|
+
class HelloAgent < Spurline::Agent
|
|
61
|
+
use_model :stub
|
|
62
|
+
|
|
63
|
+
persona(:default) do
|
|
64
|
+
system_prompt "You are a concise assistant."
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
agent = HelloAgent.new(user: "dev")
|
|
69
|
+
agent.use_stub_adapter(responses: [
|
|
70
|
+
stub_text("Hello from Spurline")
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
agent.run("Say hi") { |chunk| print chunk.text if chunk.text? }
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## CLI
|
|
77
|
+
|
|
78
|
+
Spurline ships `spur`:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
bundle exec spur help
|
|
82
|
+
bundle exec spur new my_app
|
|
83
|
+
bundle exec spur generate agent researcher
|
|
84
|
+
bundle exec spur generate tool web_scraper
|
|
85
|
+
bundle exec spur check
|
|
86
|
+
bundle exec spur console
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`spur new` now includes a project `README.md`, `.env.example`, and a starter `spec/agents/assistant_agent_spec.rb`.
|
|
90
|
+
|
|
91
|
+
## Bundled Spurs
|
|
92
|
+
|
|
93
|
+
Spurline ships bundled spur gems under `spurs/`:
|
|
94
|
+
|
|
95
|
+
- `spurline-web-search` — Brave-powered web search (`:web_search`)
|
|
96
|
+
- `spurline-test` — test framework detection, execution, and parsing (`:detect_test_framework`, `:run_tests`, `:parse_test_output`)
|
|
97
|
+
- `spurline-review` — PR diff analysis and GitHub review comment tooling (`:fetch_pr_diff`, `:analyze_diff`, `:summarize_findings`, `:post_review_comment`)
|
|
98
|
+
|
|
99
|
+
During local development you can wire a spur gem via path:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# Gemfile
|
|
103
|
+
gem "spurline-test", path: "spurs/spurline-test"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Built-in Model Aliases
|
|
107
|
+
|
|
108
|
+
Use these directly with `use_model`:
|
|
109
|
+
|
|
110
|
+
- `:claude_sonnet`
|
|
111
|
+
- `:claude_opus`
|
|
112
|
+
- `:claude_haiku`
|
|
113
|
+
- `:openai_gpt4o`
|
|
114
|
+
- `:openai_gpt4o_mini`
|
|
115
|
+
- `:openai_o3_mini`
|
|
116
|
+
- `:stub`
|
|
117
|
+
|
|
118
|
+
Adapter aliases can also come from spurs. For example, requiring `spurline/local` registers `:ollama`.
|
|
119
|
+
|
|
120
|
+
## Local Inference (Ollama)
|
|
121
|
+
|
|
122
|
+
`spurline-local` adds a local adapter backed by the Ollama HTTP API.
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
require "spurline"
|
|
126
|
+
require "spurline/local"
|
|
127
|
+
|
|
128
|
+
class LocalAgent < Spurline::Agent
|
|
129
|
+
use_model :ollama, model: "llama3.2:latest"
|
|
130
|
+
|
|
131
|
+
persona(:default) do
|
|
132
|
+
system_prompt "You are a helpful local assistant."
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
You can pass adapter kwargs directly through `use_model`:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
class RemoteOllamaAgent < Spurline::Agent
|
|
141
|
+
use_model :ollama, host: "10.0.0.1", port: 8080, model: "codellama:7b"
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`use_model` kwargs are forwarded to the adapter constructor.
|
|
146
|
+
|
|
147
|
+
## Core Concepts
|
|
148
|
+
|
|
149
|
+
- `Spurline::Agent`: public API and lifecycle
|
|
150
|
+
- `Spurline::Tools::Base`: tool contract and schema
|
|
151
|
+
- `Spurline::Security::ContextPipeline`: injection + PII + rendering gates
|
|
152
|
+
- `Spurline::Session::Session`: persistence/resumption boundary
|
|
153
|
+
- `Spurline::Audit::Log`: structured trace of LLM/tool execution
|
|
154
|
+
|
|
155
|
+
## Documentation
|
|
156
|
+
|
|
157
|
+
- [Getting Started](docs/guides/01_getting_started.md)
|
|
158
|
+
- [Agent DSL](docs/guides/02_agent_dsl.md)
|
|
159
|
+
- [Agent Lifecycle](docs/guides/03_agent_lifecycle.md)
|
|
160
|
+
- [Streaming](docs/guides/04_streaming.md)
|
|
161
|
+
- [Building Tools](docs/guides/05_building_tools.md)
|
|
162
|
+
- [Security](docs/guides/07_security.md)
|
|
163
|
+
- [Sessions and Memory](docs/guides/08_sessions_and_memory.md)
|
|
164
|
+
- [Configuration](docs/guides/12_configuration.md)
|
|
165
|
+
- [Guides Index](docs/guides/README.md)
|
|
166
|
+
|
|
167
|
+
## Development Commands
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
bundle exec rspec
|
|
171
|
+
bundle exec rspec spec/spurline/audit
|
|
172
|
+
bundle exec ruby -Ilib your_script.rb
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
MIT. See [LICENSE](LICENSE).
|
data/exe/spur
ADDED
data/lib/CLAUDE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3734 | 10:05 PM | 🔵 | Cartographer repository analysis system verified as complete and production-ready | ~1142 |
|
|
11
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3782 | 10:23 PM | 🔵 | Spurline session and state machine architecture analyzed for suspended sessions feature | ~700 |
|
|
11
|
+
| #3734 | 10:05 PM | 🔵 | Cartographer repository analysis system verified as complete and production-ready | ~1142 |
|
|
12
|
+
| #3733 | 10:04 PM | 🔵 | Cartographer repository analysis system verified complete and production-ready with all six analyzers | ~1347 |
|
|
13
|
+
| #3677 | 9:11 PM | ✅ | Version bumped to 0.2.0 | ~215 |
|
|
14
|
+
| #3664 | 8:45 PM | 🔵 | Spurline Milestone 1 implementation status audit completed | ~598 |
|
|
15
|
+
| #3661 | 7:57 PM | 🔵 | Code quality review confirmed Plans 01-02 are production-ready with zero issues | ~791 |
|
|
16
|
+
</claude-mem-context>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<claude-mem-context>
|
|
2
|
+
# Recent Activity
|
|
3
|
+
|
|
4
|
+
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
|
5
|
+
|
|
6
|
+
### Feb 21, 2026
|
|
7
|
+
|
|
8
|
+
| ID | Time | T | Title | Read |
|
|
9
|
+
|----|------|---|-------|------|
|
|
10
|
+
| #3631 | 6:00 PM | ⚖️ | Long-term memory architecture with pgvector and OpenAI embeddings | ~501 |
|
|
11
|
+
| #3632 | " | ⚖️ | OpenAI adapter architecture with stop reason normalization and tool call accumulation | ~541 |
|
|
12
|
+
</claude-mem-context>
|
|
@@ -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
|