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.
Files changed (127) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +177 -0
  4. data/exe/spur +6 -0
  5. data/lib/CLAUDE.md +11 -0
  6. data/lib/spurline/CLAUDE.md +16 -0
  7. data/lib/spurline/adapters/CLAUDE.md +12 -0
  8. data/lib/spurline/adapters/base.rb +17 -0
  9. data/lib/spurline/adapters/claude.rb +208 -0
  10. data/lib/spurline/adapters/open_ai.rb +213 -0
  11. data/lib/spurline/adapters/registry.rb +33 -0
  12. data/lib/spurline/adapters/scheduler/base.rb +15 -0
  13. data/lib/spurline/adapters/scheduler/sync.rb +15 -0
  14. data/lib/spurline/adapters/stub_adapter.rb +54 -0
  15. data/lib/spurline/agent.rb +433 -0
  16. data/lib/spurline/audit/log.rb +156 -0
  17. data/lib/spurline/audit/secret_filter.rb +121 -0
  18. data/lib/spurline/base.rb +130 -0
  19. data/lib/spurline/cartographer/CLAUDE.md +12 -0
  20. data/lib/spurline/cartographer/analyzer.rb +71 -0
  21. data/lib/spurline/cartographer/analyzers/CLAUDE.md +12 -0
  22. data/lib/spurline/cartographer/analyzers/ci_config.rb +171 -0
  23. data/lib/spurline/cartographer/analyzers/dotfiles.rb +134 -0
  24. data/lib/spurline/cartographer/analyzers/entry_points.rb +145 -0
  25. data/lib/spurline/cartographer/analyzers/file_signatures.rb +55 -0
  26. data/lib/spurline/cartographer/analyzers/manifests.rb +217 -0
  27. data/lib/spurline/cartographer/analyzers/security_scan.rb +223 -0
  28. data/lib/spurline/cartographer/repo_profile.rb +140 -0
  29. data/lib/spurline/cartographer/runner.rb +88 -0
  30. data/lib/spurline/cartographer.rb +6 -0
  31. data/lib/spurline/channels/base.rb +41 -0
  32. data/lib/spurline/channels/event.rb +136 -0
  33. data/lib/spurline/channels/github.rb +205 -0
  34. data/lib/spurline/channels/router.rb +103 -0
  35. data/lib/spurline/cli/check.rb +88 -0
  36. data/lib/spurline/cli/checks/CLAUDE.md +11 -0
  37. data/lib/spurline/cli/checks/adapter_resolution.rb +81 -0
  38. data/lib/spurline/cli/checks/agent_loadability.rb +41 -0
  39. data/lib/spurline/cli/checks/base.rb +35 -0
  40. data/lib/spurline/cli/checks/credentials.rb +43 -0
  41. data/lib/spurline/cli/checks/permissions.rb +22 -0
  42. data/lib/spurline/cli/checks/project_structure.rb +48 -0
  43. data/lib/spurline/cli/checks/session_store.rb +97 -0
  44. data/lib/spurline/cli/console.rb +73 -0
  45. data/lib/spurline/cli/credentials.rb +181 -0
  46. data/lib/spurline/cli/generators/CLAUDE.md +11 -0
  47. data/lib/spurline/cli/generators/agent.rb +123 -0
  48. data/lib/spurline/cli/generators/migration.rb +62 -0
  49. data/lib/spurline/cli/generators/project.rb +331 -0
  50. data/lib/spurline/cli/generators/tool.rb +98 -0
  51. data/lib/spurline/cli/router.rb +121 -0
  52. data/lib/spurline/configuration.rb +23 -0
  53. data/lib/spurline/dsl/CLAUDE.md +11 -0
  54. data/lib/spurline/dsl/guardrails.rb +108 -0
  55. data/lib/spurline/dsl/hooks.rb +51 -0
  56. data/lib/spurline/dsl/memory.rb +39 -0
  57. data/lib/spurline/dsl/model.rb +23 -0
  58. data/lib/spurline/dsl/persona.rb +74 -0
  59. data/lib/spurline/dsl/suspend_until.rb +53 -0
  60. data/lib/spurline/dsl/tools.rb +176 -0
  61. data/lib/spurline/errors.rb +109 -0
  62. data/lib/spurline/lifecycle/CLAUDE.md +18 -0
  63. data/lib/spurline/lifecycle/deterministic_runner.rb +207 -0
  64. data/lib/spurline/lifecycle/runner.rb +456 -0
  65. data/lib/spurline/lifecycle/states.rb +47 -0
  66. data/lib/spurline/lifecycle/suspension_boundary.rb +82 -0
  67. data/lib/spurline/memory/CLAUDE.md +12 -0
  68. data/lib/spurline/memory/context_assembler.rb +100 -0
  69. data/lib/spurline/memory/embedder/CLAUDE.md +11 -0
  70. data/lib/spurline/memory/embedder/base.rb +17 -0
  71. data/lib/spurline/memory/embedder/open_ai.rb +70 -0
  72. data/lib/spurline/memory/episode.rb +56 -0
  73. data/lib/spurline/memory/episodic_store.rb +147 -0
  74. data/lib/spurline/memory/long_term/CLAUDE.md +11 -0
  75. data/lib/spurline/memory/long_term/base.rb +22 -0
  76. data/lib/spurline/memory/long_term/postgres.rb +106 -0
  77. data/lib/spurline/memory/manager.rb +147 -0
  78. data/lib/spurline/memory/short_term.rb +57 -0
  79. data/lib/spurline/orchestration/agent_spawner.rb +151 -0
  80. data/lib/spurline/orchestration/judge.rb +109 -0
  81. data/lib/spurline/orchestration/ledger/store/base.rb +28 -0
  82. data/lib/spurline/orchestration/ledger/store/memory.rb +50 -0
  83. data/lib/spurline/orchestration/ledger.rb +339 -0
  84. data/lib/spurline/orchestration/merge_queue.rb +133 -0
  85. data/lib/spurline/orchestration/permission_intersection.rb +151 -0
  86. data/lib/spurline/orchestration/task_envelope.rb +201 -0
  87. data/lib/spurline/persona/base.rb +42 -0
  88. data/lib/spurline/persona/registry.rb +42 -0
  89. data/lib/spurline/secrets/resolver.rb +65 -0
  90. data/lib/spurline/secrets/vault.rb +42 -0
  91. data/lib/spurline/security/content.rb +76 -0
  92. data/lib/spurline/security/context_pipeline.rb +58 -0
  93. data/lib/spurline/security/gates/base.rb +36 -0
  94. data/lib/spurline/security/gates/operator_config.rb +22 -0
  95. data/lib/spurline/security/gates/system_prompt.rb +23 -0
  96. data/lib/spurline/security/gates/tool_result.rb +23 -0
  97. data/lib/spurline/security/gates/user_input.rb +22 -0
  98. data/lib/spurline/security/injection_scanner.rb +109 -0
  99. data/lib/spurline/security/pii_filter.rb +104 -0
  100. data/lib/spurline/session/CLAUDE.md +11 -0
  101. data/lib/spurline/session/resumption.rb +36 -0
  102. data/lib/spurline/session/serializer.rb +169 -0
  103. data/lib/spurline/session/session.rb +154 -0
  104. data/lib/spurline/session/store/CLAUDE.md +12 -0
  105. data/lib/spurline/session/store/base.rb +27 -0
  106. data/lib/spurline/session/store/memory.rb +45 -0
  107. data/lib/spurline/session/store/postgres.rb +123 -0
  108. data/lib/spurline/session/store/sqlite.rb +139 -0
  109. data/lib/spurline/session/suspension.rb +93 -0
  110. data/lib/spurline/session/turn.rb +98 -0
  111. data/lib/spurline/spur.rb +213 -0
  112. data/lib/spurline/streaming/CLAUDE.md +12 -0
  113. data/lib/spurline/streaming/buffer.rb +77 -0
  114. data/lib/spurline/streaming/chunk.rb +62 -0
  115. data/lib/spurline/streaming/stream_enumerator.rb +29 -0
  116. data/lib/spurline/testing.rb +245 -0
  117. data/lib/spurline/toolkit.rb +110 -0
  118. data/lib/spurline/tools/base.rb +209 -0
  119. data/lib/spurline/tools/idempotency.rb +220 -0
  120. data/lib/spurline/tools/permissions.rb +44 -0
  121. data/lib/spurline/tools/registry.rb +43 -0
  122. data/lib/spurline/tools/runner.rb +255 -0
  123. data/lib/spurline/tools/scope.rb +309 -0
  124. data/lib/spurline/tools/toolkit_registry.rb +63 -0
  125. data/lib/spurline/version.rb +5 -0
  126. data/lib/spurline.rb +56 -0
  127. 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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "spurline"
5
+
6
+ Spurline::CLI::Router.run(ARGV)
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