ask-core 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a806c5e8835fe74ef4bf8941a314ed462fa39bd3d607e6de112b92df9a8bbb8
4
- data.tar.gz: a6301f23b50af2d5e508eb73e072951a2e3526a3c0c2776a92b24b281111ef93
3
+ metadata.gz: 8cf33a5ee5e4a2e658e9e9b7244f0b368a188cb1a9a81d580003d7ed7f5bcb32
4
+ data.tar.gz: 4f0b398650815bde265b4becd15da23ae13f868ff06791b6a410e4c561775826
5
5
  SHA512:
6
- metadata.gz: 685fd487472dcb656e7b928e32d8e972ce27b963e78c8a64b167fe3a76691268878b87cf3079ff39d530cd23082d33ad919e36b01d209a790a9225c89d26b3bf
7
- data.tar.gz: ba3bae4f9839de021ac62c3aefb0e9675b159151e5d45c5b113e48c6ec042594c658568944e78075b0c38e9dc945c02be13c40dfd2cf6901ed181b7bf9184fe3
6
+ metadata.gz: 807a0ccb0851c2d1d51f4351072188f681c3356afbc74a477e4f795fc4c3456cf523b5d623f5d827bc5a76ab36d62e13348d13cba3298ddd6cd7822aa757814d
7
+ data.tar.gz: c9e02adf485410de34cbfbc8154a893aaffb2c5c72d618aeaeb6551d9a8c6482680683a5e5528a9f4e5398cef462bfd6018582e1021a1e70915b59277a8bc42d
data/CHANGELOG.md CHANGED
@@ -1,3 +1,30 @@
1
+ ## [0.9.0] — 2026-08-03
2
+
3
+ ### Changed
4
+
5
+ - **`Ask::Result` is now the single result type for the whole ecosystem** — it
6
+ supports both the foundational API (`success`/`failure`/`aborted`/`blocked`
7
+ with `content` and `status`) and the tool API (`ok`/`error` with `ok?`,
8
+ `output`, and `error_message`), including both constructor keyword sets.
9
+ Previously `ask-tools` defined its own incompatible `Ask::Result`; whichever
10
+ gem loaded last won, so `Ask::Result.success` raised `ArgumentError` in any
11
+ app loading both (e.g. every ask-agent app), and `ask-rag`'s `raw.output`
12
+ call failed on provider embedding results. ask-tools now depends on
13
+ ask-core instead of redefining the class.
14
+ - `Result#to_h` now serializes to `{ok:, output:, error:, metadata:}` (the
15
+ tool shape). `Result#inspect` is `ok=true output=...` / `ok=false error=...`.
16
+
17
+ ```ruby
18
+ Ask::Result.success("Data processed").to_h
19
+ # => {ok: true, output: "Data processed", error: nil, metadata: {}}
20
+
21
+ Ask::Result.ok(data: "Data processed") # same class, same result
22
+ ```
23
+
24
+ ### Tested
25
+
26
+ - ask-core test suite: all pass.
27
+
1
28
  ## [0.8.0] — 2026-07-28
2
29
 
3
30
  ### Changed
data/README.md CHANGED
@@ -1,284 +1,89 @@
1
1
  # ask-core
2
2
 
3
- Foundation gem for the ask-rb ecosystem. Provides the types and interfaces that every provider gem builds on.
3
+ [![Gem Version](https://badge.fury.io/rb/ask-core.svg)](https://badge.fury.io/rb/ask-core)
4
4
 
5
- **Zero external dependencies.** Uses only Ruby stdlib (`json`, `net/http`, `date`, `time`).
5
+ Foundation gem for the ask-rb ecosystem. Provides the value objects and interfaces every other gem builds on: messages, conversations, streaming primitives, the provider contract, model catalog, tool definitions, and structured errors. Zero external dependencies, Ruby stdlib only (`json`, `net/http`, `date`, `time`).
6
6
 
7
7
  ## Installation
8
8
 
9
9
  ```ruby
10
- # In your Gemfile
11
10
  gem "ask-core"
12
11
  ```
13
12
 
14
- ## What it provides
15
-
16
- | Component | File | Purpose |
17
- |---|---|---|
18
- | `Ask::Provider` | `lib/ask/provider.rb` | Abstract base class for all LLM providers |
19
- | `Ask::Conversation` | `lib/ask/conversation.rb` | Message container with role normalization |
20
- | `Ask::Stream` / `Ask::Chunk` | `lib/ask/stream.rb` | Streaming primitives |
21
- | `Ask::ModelCatalog` | `lib/ask/models.rb` | Model name to provider resolution |
22
- | `Ask::ToolDef` | `lib/ask/tool_def.rb` | Immutable tool metadata struct |
23
- | `Ask::Result` | `lib/ask/result.rb` | Standardized tool return value |
24
- | `Ask::Error` | `lib/ask/errors.rb` | Structured error types |
25
-
26
- ## Usage
27
-
28
- ### Provider (abstract base class)
29
-
30
- Provider gems subclass `Ask::Provider` and implement the abstract methods:
13
+ ## Quick Start
31
14
 
32
15
  ```ruby
33
- class MyProvider < Ask::Provider
34
- def api_base
35
- "https://api.example.com/v1"
36
- end
37
-
38
- def headers
39
- { "Authorization" => "Bearer #{@config.api_key}" }
40
- end
41
-
42
- def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
43
- # Return an Ask::Message or yield Ask::Chunks
44
- end
45
-
46
- def embed(text, model:)
47
- # Return an array of floats
48
- end
49
-
50
- def list_models
51
- # Return an array of Ask::ModelInfo
52
- end
53
-
54
- class << self
55
- def configuration_options
56
- [:api_key, :api_base]
57
- end
58
-
59
- def configuration_requirements
60
- [:api_key]
61
- end
62
- end
63
- end
64
-
65
- # Register the provider
66
- Ask::Provider.register(:my_provider, MyProvider)
67
-
68
- # Resolve by name
69
- Ask::Provider.resolve(:my_provider) # => MyProvider
70
- ```
71
-
72
- ### Conversation
73
-
74
- Build and manipulate conversations with role-normalized messages:
16
+ require "ask-core"
75
17
 
76
- ```ruby
77
18
  conv = Ask::Conversation.new
78
-
79
- # Convenience methods
80
19
  conv.system("You are a helpful assistant.")
81
20
  conv.user("What's the weather in Tokyo?")
82
- conv.assistant("Let me check...", tool_calls: [{ name: "get_weather", arguments: { location: "Tokyo" } }])
83
- conv.tool_result("72°F, sunny", tool_call_id: "call_123")
84
-
85
- # Iteration
86
- conv.each { |msg| puts "#{msg.role}: #{msg.content}" }
87
21
 
88
- # Filtering by role
89
- conv.user_messages # => [Ask::Message, ...]
90
- conv.system_messages # => [Ask::Message, ...]
22
+ conv.last.role # => :user
23
+ conv.last.user? # => true
91
24
 
92
- # Serialization
93
- conv.to_a # => [{ role: :user, content: "..." }, ...]
25
+ # Serialize for a provider API
26
+ conv.to_a # => [{ role: :user, content: "..." }, ...]
94
27
  ```
95
28
 
96
- ### Messages
29
+ ## The core types
97
30
 
98
- ```ruby
99
- msg = Ask::Message.new(role: :user, content: "Hello")
100
- msg.user? # => true
101
- msg.system? # => false
102
- msg.assistant? # => false
103
- msg.tool? # => false
31
+ | Type | Purpose |
32
+ |---|---|
33
+ | `Ask::Conversation`, `Ask::Message` | Message container with role normalization (`:system`, `:user`, `:assistant`, `:tool`) and immutable message value objects |
34
+ | `Ask::Stream`, `Ask::Chunk` | Streaming primitives with text accumulation and usage tracking |
35
+ | `Ask::Provider` | Abstract base class for LLM providers, with a thread-safe registry (`register` / `resolve`) |
36
+ | `Ask::ModelCatalog`, `Ask::ModelInfo` | Model metadata: find by ID/provider, filter by family, refresh from models.dev |
37
+ | `Ask::ToolDef` | Immutable tool metadata for provider function calling |
38
+ | `Ask::Result` | Standardized tool return value: `success`, `failure`, `aborted`, `blocked` |
39
+ | `Ask::Content` | Multi-modal content blocks: `Text`, `Image`, `Audio`, `Video`, `File` |
40
+ | `Ask::Document` | Text + metadata value object for RAG pipelines |
41
+ | `Ask::ProviderTool` | Provider-executed tools (e.g. `web_search`, `file_search`) |
42
+ | `Ask::State::Adapter` | Abstract contract for state backends (implemented by ask-state-providers) |
43
+ | `Ask::Error` and subclasses | Structured errors (`ConfigurationError`, `RateLimitError`, `ProviderError`, and more) |
104
44
 
105
- msg = Ask::Message.new(role: :assistant, tool_calls: [{ name: "f", arguments: {} }])
106
- msg.tool_call? # => true
45
+ ## Defining a provider
107
46
 
108
- msg = Ask::Message.new(role: :tool, content: "result", tool_call_id: "call_1")
109
- msg.tool_result? # => true
110
- ```
111
-
112
- Valid roles: `:system`, `:user`, `:assistant`, `:tool`
113
-
114
- ### Streaming
47
+ Provider gems subclass `Ask::Provider`, implement the abstract methods, and register themselves:
115
48
 
116
49
  ```ruby
117
- stream = Ask::Stream.new
118
-
119
- # Add chunks as they arrive from the provider
120
- stream.add(Ask::Chunk.new(content: "Hello "))
121
- stream.add(Ask::Chunk.new(content: "World"))
122
- stream.add(Ask::Chunk.new(content: "", finish_reason: "stop"))
123
- stream.finish!
124
-
125
- # Accumulate the full response
126
- stream.accumulated_text # => "Hello World"
127
- stream.to_s # => "Hello World"
128
-
129
- # Track token usage
130
- stream.accumulated_usage # => { input_tokens: 10, output_tokens: 20 }
131
-
132
- # Iterate
133
- stream.each { |chunk| print chunk.content }
134
- ```
135
-
136
- ### Chunks
137
-
138
- ```ruby
139
- chunk = Ask::Chunk.new(content: "Hello")
140
- chunk.content # => "Hello"
141
- chunk.finished? # => false
142
- chunk.tool_call? # => false
143
- chunk.finish_reason # => nil (or "stop", "length", "tool_calls")
144
-
145
- chunk = Ask::Chunk.new(tool_calls: [{ name: "get_weather" }])
146
- chunk.tool_call? # => true
147
-
148
- chunk = Ask::Chunk.new(usage: { input_tokens: 10, output_tokens: 20 })
149
- chunk.usage # => { input_tokens: 10, output_tokens: 20 }
150
- ```
151
-
152
- ### Model Catalog
153
-
154
- Query available models from the registry:
155
-
156
- ```ruby
157
- catalog = Ask::ModelCatalog.new([
158
- Ask::ModelInfo.new(id: "gpt-4o", provider: "openai", capabilities: ["function_calling", "vision"]),
159
- Ask::ModelInfo.new(id: "claude-sonnet-4", provider: "anthropic", capabilities: ["function_calling", "reasoning"])
160
- ])
161
-
162
- # Find by ID (prefers most common provider)
163
- catalog.find("gpt-4o")
164
-
165
- # Find with specific provider
166
- catalog.find("gpt-4o", "openai")
167
-
168
- # Filter by type
169
- catalog.chat_models
170
- catalog.embedding_models
171
-
172
- # Filter by provider or family
173
- catalog.by_provider("openai")
174
- catalog.by_family("gpt")
175
-
176
- # Singleton instance
177
- Ask::ModelCatalog.instance
178
- Ask::ModelCatalog.find("gpt-4o")
179
- ```
180
-
181
- ### ModelInfo
182
-
183
- ```ruby
184
- info = Ask::ModelInfo.new(
185
- id: "gpt-4o",
186
- provider: "openai",
187
- capabilities: ["function_calling", "vision"],
188
- context_window: 128_000,
189
- pricing: { text_tokens: { standard: { input_per_million: 2.5, output_per_million: 10 } } }
190
- )
191
-
192
- info.supports?(:function_calling) # => true
193
- info.chat? # => true
194
- info.embedding? # => false
195
- info.context_window # => 128_000
196
- ```
197
-
198
- ### Tool Definitions
199
-
200
- Immutable tool metadata for provider function calling:
201
-
202
- ```ruby
203
- tool = Ask::ToolDef.new(
204
- name: "get_weather",
205
- description: "Get current weather for a location",
206
- parameters: {
207
- type: "object",
208
- properties: {
209
- location: { type: "string", description: "City name" },
210
- unit: { type: "string", enum: ["celsius", "fahrenheit"] }
211
- },
212
- required: ["location"]
213
- }
214
- )
215
-
216
- tool.name # => "get_weather"
217
- tool.description # => "Get current weather for a location"
50
+ class MyProvider < Ask::Provider
51
+ def api_base = "https://api.example.com/v1"
52
+ def chat(messages, model:, **opts) = Ask::Message.new(role: :assistant, content: "Hello")
53
+ def embed(text, model:) = [0.1, 0.2, 0.3]
54
+ def list_models = [Ask::ModelInfo.new(id: "my-model", provider: "my_provider")]
55
+ end
218
56
 
219
- # Provider-specific format
220
- tool.to_provider_format { |t| { type: "function", function: t.to_h } }
57
+ Ask::Provider.register(:my_provider, MyProvider)
58
+ Ask::Provider.resolve(:my_provider) # => MyProvider
221
59
  ```
222
60
 
223
- ### Tool Results
61
+ ## Architecture & ownership
224
62
 
225
- Standardized return values from tool execution:
63
+ ask-core owns the shared value objects and base contracts (`Ask::Message`,
64
+ `Ask::Conversation`, `Ask::Content`, `Ask::Stream`, `Ask::Provider`,
65
+ `Ask::Result`, `Ask::ModelCatalog`, `Ask::ToolDef`, `Ask::Document`, the
66
+ `Ask::Error` hierarchy). It is zero-dependency on purpose: `ask-llm-providers`,
67
+ `ask-rag`, `ask-graph`, and `ask-state-providers` depend on it *without*
68
+ pulling in the tool framework.
226
69
 
227
- ```ruby
228
- Ask::Result.success("Data processed")
229
- Ask::Result.success(updated_record, metadata: { duration: 1.2 })
230
- Ask::Result.failure("API returned 500", error: "Timeout")
231
- Ask::Result.aborted("Cancelled by sibling failure")
232
- Ask::Result.blocked("Permission denied")
70
+ Feature gems extend what ask-core provides — they never redefine it.
71
+ `Ask::Result` is the single result type for the whole ecosystem: foundational
72
+ API (`success`/`failure`/`aborted`/`blocked`) and tool API (`ok`/`error`) in
73
+ one class. See
74
+ [Architecture & Ownership](https://ask-rb.github.io/ask-docs/reference/architecture).
233
75
 
234
- result = Ask::Result.success("OK")
235
- result.success? # => true
236
- result.error? # => false
237
- result.aborted? # => false
238
- result.blocked? # => false
239
- result.to_s # => "OK"
240
- result.to_h # => { content: "OK", status: :success, metadata: {} }
241
- ```
76
+ ## Full documentation
242
77
 
243
- ### Error Types
244
-
245
- ```ruby
246
- Ask::Error # Base class (rescue Ask::Error to catch all)
247
- Ask::ConfigurationError # Missing/incorrect configuration
248
- Ask::UnknownProvider # Provider not registered
249
- Ask::ModelNotFound # Model not in catalog
250
- Ask::InvalidRole # Invalid message role
251
- Ask::InvalidToolDefinition # Invalid tool name/definition
252
- Ask::ProviderError # Provider API error (with status_code, response_body)
253
- Ask::ContextLengthExceeded # Context window exceeded
254
- Ask::RateLimitError # Rate limited
255
- Ask::Unauthorized # Authentication failure
256
- Ask::ServerError # 5xx server error
257
- Ask::ServiceUnavailable # Service temporarily unavailable
258
- Ask::UnsupportedFeature # Feature not supported by provider/model
259
- Ask::MissingCredential # Required credential not found
260
- Ask::InvalidCredential # Credential is invalid/expired
261
- ```
78
+ The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs. [ask-core in depth](https://ask-rb.github.io/ask-docs/core/ask-core) covers the types, streaming, and the provider contract. API reference: https://ask-rb.github.io/ask-docs/reference/api.
262
79
 
263
80
  ## Development
264
81
 
265
- ```bash
82
+ ```
83
+ bundle install
266
84
  bundle exec rake test
267
85
  ```
268
86
 
269
- ## Testing
270
-
271
- - Uses Minitest (not RSpec) — consistent with the ask-rb ecosystem.
272
- - Unit tests for every public method.
273
- - Run the full suite before every commit: `bundle exec rake test`.
274
-
275
- ## Design Principles
276
-
277
- 1. **Zero runtime dependencies** — stdlib only. Provider gems add their own HTTP clients.
278
- 2. **Immutable value objects** — `Message`, `ToolDef`, `Result`, `Chunk`, and `ModelInfo` are frozen after construction.
279
- 3. **Abstract interface** — `Ask::Provider` defines the contract. Provider gems implement the wire format.
280
- 4. **Provider registry** — providers register themselves for runtime resolution by name.
281
-
282
87
  ## License
283
88
 
284
89
  MIT
data/lib/ask/result.rb CHANGED
@@ -1,12 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- # Standardized return value from tool execution. Wraps the outcome of a tool
5
- # call with status, content, and optional error metadata.
4
+ # Standardized return value from tool execution.
5
+ #
6
+ # This is the single +Ask::Result+ for the whole ecosystem. It supports both
7
+ # the foundational API (+success+/+failure+/+aborted+/+blocked+ with
8
+ # +content+ and +status+) and the tool API (+ok+/+error+ with +ok?+,
9
+ # +output+, and +error_message+):
6
10
  #
7
11
  # Ask::Result.success("Data processed")
8
- # Ask::Result.failure("API returned 500")
12
+ # Ask::Result.ok(data: "Data processed")
13
+ #
14
+ # Both are the same class and share the same +to_h+ shape, so a result
15
+ # created by a provider's +embed+ or a tool's +execute+ can be inspected
16
+ # uniformly.
9
17
  #
18
+ # Feature gems extend this class or build on it — they never redefine it.
19
+ # See ask-docs "Architecture & ownership" for the rule.
10
20
  class Result
11
21
  STATUSES = %i[success error aborted blocked short_circuited].freeze
12
22
 
@@ -23,11 +33,12 @@ module Ask
23
33
 
24
34
  # Create a failure result.
25
35
  # @param message [String] the error description
26
- # @param error [Object, nil] the underlying error object
36
+ # @param error [Object, nil] the underlying error object (defaults to
37
+ # +message+ so `failure(msg).error` reads as the message)
27
38
  # @param metadata [Hash] additional metadata
28
39
  # @return [Ask::Result]
29
40
  def failure(message, error: nil, metadata: {})
30
- new(content: message, status: :error, error: error, metadata: metadata)
41
+ new(content: message, status: :error, error: error.nil? ? message : error, metadata: metadata)
31
42
  end
32
43
 
33
44
  # Create an aborted result (cancelled by sibling failure).
@@ -43,24 +54,58 @@ module Ask
43
54
  def blocked(reason)
44
55
  new(content: reason, status: :blocked)
45
56
  end
57
+
58
+ # Create a successful result (tool API — alias for +success+).
59
+ # @param data [Object] the tool's output
60
+ # @param metadata [Hash] optional metadata
61
+ # @return [Ask::Result]
62
+ def ok(data:, metadata: {})
63
+ new(content: data, status: :success, metadata: metadata)
64
+ end
65
+
66
+ # Create a failed result (tool API).
67
+ # @param message [String] description of the failure
68
+ # @param metadata [Hash] optional metadata
69
+ # @return [Ask::Result]
70
+ def error(message:, metadata: {})
71
+ new(content: message, status: :error, error: message, metadata: metadata)
72
+ end
46
73
  # @!endgroup
47
74
  end
48
75
 
49
- # @return [Object, nil] the result content
76
+ # @return [Object, nil] the result content (for +success+/+ok+ results,
77
+ # this is the payload; for +failure+ it is the error message)
50
78
  attr_reader :content
51
79
 
52
80
  # @return [Symbol] the status (:success, :error, :aborted, :blocked, :short_circuited)
53
81
  attr_reader :status
54
82
 
55
- # @return [Object, nil] the underlying error, if any
83
+ # @return [Object, nil] the error message or underlying error object, if any
56
84
  attr_reader :error
57
85
 
58
86
  # @return [Hash] additional metadata
59
87
  attr_reader :metadata
60
88
 
61
- def initialize(content: nil, status: :success, error: nil, metadata: {})
62
- @content = content
63
- @status = validate_status!(status)
89
+ # Accepts both the foundational keywords (+content+, +status+) and the
90
+ # tool keywords (+ok+, +output+). +ok:+ derives the status: +true+ means
91
+ # +:success+, +false+ means +:error+.
92
+ #
93
+ # @param content [Object, nil] result content
94
+ # @param output [Object, nil] tool output (takes precedence over +content+)
95
+ # @param status [Symbol] result status
96
+ # @param ok [Boolean, nil] whether the result is a success (derives status)
97
+ # @param error [Object, nil] error message or underlying error object
98
+ # @param metadata [Hash] additional metadata
99
+ def initialize(content: nil, output: nil, status: :success, ok: nil, error: nil, metadata: {})
100
+ @content = output.nil? ? content : output
101
+ @status = if ok == true
102
+ :success
103
+ elsif ok == false
104
+ :error
105
+ else
106
+ status
107
+ end
108
+ @status = validate_status!(@status)
64
109
  @error = error
65
110
  @metadata = metadata.dup.freeze
66
111
  freeze
@@ -69,6 +114,12 @@ module Ask
69
114
  # @return [Boolean] true if status is :success
70
115
  def success? = @status == :success
71
116
 
117
+ # @return [Boolean] true if the result is a success (tool API)
118
+ def ok? = @status == :success
119
+
120
+ # @return [Boolean] true if the result is a success (tool API)
121
+ def ok = @status == :success
122
+
72
123
  # @return [Boolean] true if status is :error
73
124
  def error? = @status == :error
74
125
 
@@ -78,7 +129,13 @@ module Ask
78
129
  # @return [Boolean] true if status is :blocked
79
130
  def blocked? = @status == :blocked
80
131
 
81
- # @return [String] the content as a string
132
+ # @return [Object, nil] the output data when successful, nil otherwise (tool API)
133
+ def output = @status == :success ? @content : nil
134
+
135
+ # @return [Object, nil] the error message or underlying error object
136
+ def error_message = @error
137
+
138
+ # @return [String] the content as a string (for failures, the message)
82
139
  def to_s
83
140
  @content.to_s
84
141
  end
@@ -86,16 +143,20 @@ module Ask
86
143
  # @return [Hash] serialized representation
87
144
  def to_h
88
145
  {
89
- content: @content,
90
- status: @status,
146
+ ok: @status == :success,
147
+ output: @status == :success ? @content : nil,
91
148
  error: @error,
92
149
  metadata: @metadata
93
- }.compact
150
+ }
94
151
  end
95
152
 
96
153
  # @return [String] human-readable representation
97
154
  def inspect
98
- "#<Ask::Result status=#{@status.inspect} content=#{@content.inspect}>"
155
+ if @status == :success
156
+ "#<Ask::Result ok=true output=#{@content.inspect}>"
157
+ else
158
+ "#<Ask::Result ok=false error=#{@error.inspect} status=#{@status.inspect}>"
159
+ end
99
160
  end
100
161
 
101
162
  private
data/lib/ask/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ask
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto