riffer 0.43.0 → 0.44.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/CHANGELOG.md +8 -0
  4. data/Steepfile +1 -0
  5. data/docs/AGENTS.md +23 -2
  6. data/docs/CONFIGURATION.md +32 -0
  7. data/docs/MESSAGES.md +10 -0
  8. data/docs/OVERVIEW.md +1 -0
  9. data/docs/TESTING.md +129 -0
  10. data/docs/TOOLS.md +23 -2
  11. data/docs/providers/AMAZON_BEDROCK.md +17 -0
  12. data/docs/providers/GEMINI.md +2 -3
  13. data/docs-site/manifest.yml +3 -0
  14. data/lib/riffer/config.rb +94 -0
  15. data/lib/riffer/files/downloader.rb +78 -0
  16. data/lib/riffer/files/resolver.rb +97 -0
  17. data/lib/riffer/files.rb +5 -0
  18. data/lib/riffer/helpers/identifier.rb +11 -3
  19. data/lib/riffer/messages/file_part.rb +61 -9
  20. data/lib/riffer/providers/amazon_bedrock.rb +7 -7
  21. data/lib/riffer/providers/base.rb +8 -0
  22. data/lib/riffer/providers/gemini.rb +6 -5
  23. data/lib/riffer/providers/open_ai.rb +6 -0
  24. data/lib/riffer/providers/open_router.rb +6 -0
  25. data/lib/riffer/registrable.rb +105 -22
  26. data/lib/riffer/testing/minitest.rb +21 -0
  27. data/lib/riffer/testing/rspec.rb +11 -0
  28. data/lib/riffer/testing.rb +130 -0
  29. data/lib/riffer/version.rb +1 -1
  30. data/lib/riffer.rb +31 -0
  31. data/sig/_private/minitest.rbs +9 -0
  32. data/sig/_private/riffer/testing/minitest.rbs +6 -0
  33. data/sig/_private/rspec.rbs +7 -0
  34. data/sig/_private/zeitwerk.rbs +2 -0
  35. data/sig/generated/riffer/config.rbs +64 -0
  36. data/sig/generated/riffer/files/downloader.rbs +16 -0
  37. data/sig/generated/riffer/files/resolver.rbs +36 -0
  38. data/sig/generated/riffer/files.rbs +4 -0
  39. data/sig/generated/riffer/helpers/identifier.rbs +8 -0
  40. data/sig/generated/riffer/messages/file_part.rbs +38 -6
  41. data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
  42. data/sig/generated/riffer/providers/base.rbs +4 -0
  43. data/sig/generated/riffer/providers/gemini.rbs +4 -0
  44. data/sig/generated/riffer/providers/open_ai.rbs +4 -0
  45. data/sig/generated/riffer/providers/open_router.rbs +4 -0
  46. data/sig/generated/riffer/registrable.rbs +57 -9
  47. data/sig/generated/riffer/testing/rspec.rbs +2 -0
  48. data/sig/generated/riffer/testing.rbs +75 -0
  49. data/sig/generated/riffer.rbs +32 -0
  50. data/sig/manifest.yaml +1 -0
  51. data/sig/manual/riffer/testing.rbs +5 -0
  52. metadata +31 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bed1103d2399d8532635b4041d4a24b8264f160b58a3110436ed67a14380fd56
4
- data.tar.gz: acdaa65506e0c4887d388663be686bc02ccf668c26db281148efcec9f5772d6b
3
+ metadata.gz: 7f7862ed670d852eee1e076386032449773a8782424fbfc9bc80e96aab61b1e3
4
+ data.tar.gz: b520d7629441e6acd75a677bc3d094359f5a5b7f31c2986d6aadd51f2935b3e1
5
5
  SHA512:
6
- metadata.gz: 0733aa1731a7206195862e6bfd59936b80cc3219fa913a6afce577a8de9d8f4510ca59316a6bb27c6a778706b6d231ce61cd74ebd9d63f32d2a695453b2bd5ae
7
- data.tar.gz: da09bd24389e52f79c7cbe3dd977c5ada7bc9f0a923f2d09d2331245b4ca1fd079f9f688da0ab8b36eac6b1a260bd627831dc0c344ae93422f5a006c929a11e3
6
+ metadata.gz: '069d1d96193748ffdc48eb95a8fe22b57c6293a33e7ba4658a01fda4e5c4a7847145bb234605c0507059830c300dc2d85ec295ecf2d59de20265385644d6dc89'
7
+ data.tar.gz: 86bbcfb5408f3b5ba0fbb327dff9c2a98c0ee410e3ce4ab72c89a3a66d8aaa8e4d095e2ee14bc32c814caa28503cf0d099095441a60eb4cb9e11b7639f522d65
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.43.0"
2
+ ".": "0.44.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.44.0](https://github.com/janeapp/riffer/compare/riffer/v0.43.0...riffer/v0.44.0) (2026-08-28)
9
+
10
+
11
+ ### Features
12
+
13
+ * file attachment downloading for user messages ([#407](https://github.com/janeapp/riffer/issues/407)) ([4e78868](https://github.com/janeapp/riffer/commit/4e78868f7d1efe59c35906fff2ba7594ccbd6de7))
14
+ * registry liveness, explicit registration, and Riffer::Testing stub factories ([#415](https://github.com/janeapp/riffer/issues/415)) ([6935749](https://github.com/janeapp/riffer/commit/69357496b094fcfa9e72d12a525645374ef6e352))
15
+
8
16
  ## [0.43.0](https://github.com/janeapp/riffer/compare/riffer/v0.42.0...riffer/v0.43.0) (2026-08-27)
9
17
 
10
18
 
data/Steepfile CHANGED
@@ -14,6 +14,7 @@ target :lib do
14
14
  library "aws-sdk-core"
15
15
  library "base64"
16
16
  library "cgi"
17
+ library "digest"
17
18
  library "json"
18
19
  library "logger"
19
20
  library "net-http"
data/docs/AGENTS.md CHANGED
@@ -321,11 +321,32 @@ Riffer::Agent.find('missing') # => nil
321
321
  Riffer::Agent.all # => [SupportAgent, ...]
322
322
  ```
323
323
 
324
- Only **named direct subclasses** are found:
324
+ Only **named direct subclasses** are found implicitly:
325
325
 
326
326
  - Grandchildren are not visible to a grandparent's `find` or `all`. If your app defines an intermediate base class (`class ApplicationAgent < Riffer::Agent`), call `find`/`all` on the intermediate class to look up its subclasses.
327
- - Anonymous classes (`Class.new(Riffer::Agent)`) are never findable, even when they set an explicit `identifier`.
327
+ - Anonymous classes (`Class.new(Riffer::Agent)`) are never findable implicitly, even when they set an explicit `identifier` — see [Registering an agent explicitly](#registering-an-agent-explicitly).
328
328
  - Two subclasses sharing an identifier raise `Riffer::DuplicateIdentifierError` at the first lookup.
329
+ - A subclass whose constant no longer points at it — after a Zeitwerk reload or an RSpec `stub_const` — drops out of `find` and `all`. The check runs when the registry is rebuilt, which defining, registering, or unregistering a subclass triggers; removing or restoring a constant on its own does not, so lookups keep returning the old class until the next rebuild.
330
+
331
+ ### Registering an agent explicitly
332
+
333
+ **Testing an agent that gets resolved by identifier? Use [`Riffer::Testing`](TESTING.md)** — `stub_agent` builds and registers a throwaway agent and cleans it up for you. The API below is the manual alternative, for production wiring and anything outside the stub lifecycle.
334
+
335
+ `register` adds an agent to its parent's registry by hand and `unregister` removes it:
336
+
337
+ ```ruby
338
+ Riffer::Agent.register(agent) # findable until unregistered
339
+ Riffer::Agent.unregister(agent) # no-op if it was never registered
340
+ ```
341
+
342
+ Explicit registration differs from implicit in a few ways:
343
+
344
+ - Anonymous classes are allowed, as long as they declare an `identifier`. A blank identifier raises `Riffer::ArgumentError`.
345
+ - The class must be a **direct** subclass of the receiver, mirroring implicit registration. `Riffer::Agent.register(SomeAppAgent)` raises `Riffer::ArgumentError` when `SomeAppAgent` descends from an intermediate base — call `register` on that base instead.
346
+ - Taking an identifier already held by another agent, implicit or explicit, raises `Riffer::DuplicateIdentifierError`. Re-registering the same class raises too; there is no idempotent path.
347
+ - The registration survives until you remove it — it is never dropped for a stale constant.
348
+
349
+ Registration is not synchronized. Register during boot or from a single-threaded test, before concurrent lookups begin.
329
350
 
330
351
  ## Per-Call Tags
331
352
 
@@ -182,6 +182,38 @@ end
182
182
  | `capture_messages` | Opt-in capture of full message content on LLM-call spans (`gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions`) as GenAI-semconv JSON. Defaults to `false` — message content routinely carries sensitive data. File attachments serialize as metadata-only stubs (media type and name, never bytes), and riffer applies no size limit of its own — cap oversized attributes with the OTEL SDK attribute length limits. |
183
183
  | `backend` | The backend riffer routes spans through. Assign `Riffer::Tracing::Otel.build` (pass `provider:` to override the global tracer provider — e.g. an in-memory provider in tests), or any object satisfying the duck-typed contract (`in_span` / `current_context` / `with_context`) to route into a non-OTEL system (e.g. Datadog APM). Defaults to `nil` — a no-op. Raises `Riffer::ArgumentError` unless the value is `nil` or responds to `in_span`. See [Tracing → Routing to a non-OpenTelemetry backend](TRACING.md#routing-to-a-non-opentelemetry-backend). |
184
184
 
185
+ ### File Downloads
186
+
187
+ File-attachment-download policy lives under `config.files`. Before an LLM call, riffer resolves every `Riffer::Messages::FilePart` attached to a user message against the provider's own capability — some providers accept a URL as-is, some need the bytes inline, and some can't take an attachment at all. See [Messages — File Parts](MESSAGES.md#file-parts) for `FilePart` itself and its `sha256:` field.
188
+
189
+ ```ruby
190
+ Riffer.configure do |config|
191
+ config.files.allow_downloads = true
192
+ config.files.max_bytes = 5_000_000
193
+ config.files.max_per_message = 4
194
+ end
195
+ ```
196
+
197
+ Per file, riffer applies this policy, in order:
198
+
199
+ 1. **Already inline data** — nothing to download. If `sha256:` was given, it's verified against the existing bytes regardless of any other setting below.
200
+ 2. **Provider can't accept the file at all** — raises `Riffer::FileUnsupportedError`.
201
+ 3. **Provider accepts a URL as-is** — passed straight through, untouched, *unless* `sha256:` was given, in which case riffer downloads and verifies anyway (a caller who set `sha256:` is asking for integrity verification, not a passthrough).
202
+ 4. **Provider needs the bytes inline** — riffer downloads the file, verifying `sha256:` if given.
203
+
204
+ Every download in step 3 or 4 is gated by `allow_downloads`; with it `false` (the default), reaching either of those steps raises `Riffer::FileDownloadsDisabledError` instead of fetching anything. This means upgrading to a riffer version with this feature never starts downloading arbitrary URLs on your behalf — you have to opt in.
205
+
206
+ | Option | Description |
207
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `allow_downloads` | Whether riffer may download a `FilePart`'s URL. Accepts booleans or `'true'`/`'false'`/`'1'`/`'0'`. Defaults to `false`. |
209
+ | `max_bytes` | Maximum size, in bytes, of a downloaded file; the download is aborted once the streamed body exceeds this, independent of (and regardless of a missing/lying) `content-length` header. Defaults to `3_500_000`. |
210
+ | `timeout` | Open and read timeout, in seconds, for a single download attempt. Defaults to `60`. |
211
+ | `max_per_message` | Maximum number of files allowed on a single user message; checked against each message as originally authored, before consecutive messages are merged. `nil` (default) means uncapped. |
212
+ | `runner` | A `Riffer::Runner` instance that resolves every file across a call's messages. Defaults to `Riffer::Runner::Sequential.new`; assign `Riffer::Runner::Threaded.new` (or `Riffer::Runner::Fibers.new` inside a fiber-based host) to resolve multiple files concurrently. |
213
+ | `downloader` | The object that fetches a URL's bytes; must respond to `#call(url, max_bytes:, timeout:)` returning the raw (not base64-encoded) file content. Riffer caches it as base64 or raw bytes, whichever the provider actually needs, rather than producing both. Defaults to `Riffer::Files::Downloader.new`, which fetches over HTTPS only, following up to 3 redirects. Assign your own to add logging/metrics, or to fetch from a non-HTTPS store (e.g. `s3://`). |
214
+
215
+ A file that fails resolution raises a `Riffer::FileError` subclass — `Riffer::FileUnsupportedError`, `Riffer::FileDownloadsDisabledError`, `Riffer::TooManyFilesError`, `Riffer::FileChecksumMismatchError`, `Riffer::FileTooLargeError`, `Riffer::FileDownloadError`, or `Riffer::FileEncodingError` — so callers can `rescue Riffer::FileError` for any attachment problem, or a specific subclass to handle one case.
216
+
185
217
  ### Pricing
186
218
 
187
219
  Configure per-model token prices and riffer computes the cost of each LLM call onto its [`TokenUsage`](MESSAGES.md#token-usage-semantics). Riffer ships **no** price table — so an unconfigured model simply carries no cost (`token_usage.cost` is `nil`).
data/docs/MESSAGES.md CHANGED
@@ -176,8 +176,17 @@ file.document? # => true
176
176
 
177
177
  # From raw base64 data
178
178
  file = Riffer::Messages::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")
179
+
180
+ # With an expected sha256 checksum of the file's contents
181
+ file = Riffer::Messages::FilePart.from_url(
182
+ "https://example.com/doc.pdf",
183
+ sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
184
+ )
185
+ file.sha256 # => "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
179
186
  ```
180
187
 
188
+ `sha256:` accepts a 64-character hex digest (case-insensitive; normalized to lowercase). Raises `Riffer::ArgumentError` on any other format.
189
+
181
190
  ### Hash Shorthand
182
191
 
183
192
  When passing files to agents or messages, hashes are automatically converted:
@@ -189,6 +198,7 @@ When passing files to agents or messages, hashes are automatically converted:
189
198
  # URL shorthand (media_type auto-detected from extension, or provide explicitly)
190
199
  {url: "https://example.com/photo.jpg"}
191
200
  {url: "https://example.com/file", media_type: "application/pdf"}
201
+ {url: "https://example.com/file", media_type: "application/pdf", sha256: "e3b0c4..."}
192
202
 
193
203
  # Data shorthand
194
204
  {data: base64_string, media_type: "image/png", filename: "chart.png"}
data/docs/OVERVIEW.md CHANGED
@@ -133,6 +133,7 @@ Response
133
133
  - [Configuration](CONFIGURATION.md) - Global configuration
134
134
  - [Tracing](TRACING.md) - OpenTelemetry span contract and host wiring
135
135
  - [Evals](EVALS.md) - Evaluating agent quality
136
+ - [Testing](TESTING.md) - Stubbing agents and tools in tests
136
137
  - [Guardrails](GUARDRAILS.md) - Input/output validation
137
138
  - [Skills](SKILLS.md) - Packaged agent capabilities
138
139
  - [Serialization](SERIALIZATION.md) - Persisting and transferring agent definitions
data/docs/TESTING.md ADDED
@@ -0,0 +1,129 @@
1
+ # Testing
2
+
3
+ `Riffer::Testing` builds throwaway agents and tools your suite can resolve by identifier or by constant name, and removes them again when the test ends. It exists because agents and tools are looked up by identifier at runtime (see [Looking Up Tools](TOOLS.md#looking-up-tools)) and only **named** classes are found implicitly — an anonymous `Class.new(Riffer::Tool)` never is.
4
+
5
+ ## Setup
6
+
7
+ Require the adapter for your framework once, in your spec/test helper:
8
+
9
+ ```ruby
10
+ # spec/spec_helper.rb
11
+ require 'riffer/testing/rspec'
12
+ ```
13
+
14
+ ```ruby
15
+ # test/test_helper.rb
16
+ require 'riffer/testing/minitest'
17
+ ```
18
+
19
+ Either one adds `stub_agent` and `stub_tool` to every example and cleans up after each one. Neither RSpec nor minitest is a riffer dependency; the adapter files are only loaded when you require them yourself.
20
+
21
+ ## Stubbing a tool
22
+
23
+ ```ruby
24
+ it 'answers from the knowledge base' do
25
+ stub_tool('KbSearch') do
26
+ description 'Searches the knowledge base.'
27
+
28
+ def call(context:, **)
29
+ text('We are open 9-5.')
30
+ end
31
+ end
32
+
33
+ response = SupportAgent.generate('What are your hours?')
34
+
35
+ expect(response.content).to include('9-5')
36
+ end
37
+ ```
38
+
39
+ `stub_tool` returns the class, so you can assert against it or pass it to `uses_tools`:
40
+
41
+ ```ruby
42
+ tool = stub_tool('KbSearch') { description 'Searches the knowledge base.' }
43
+
44
+ stub_agent('FaqAgent') { uses_tools [tool] }
45
+ ```
46
+
47
+ The block is evaluated in the new class, so the whole [tool DSL](TOOLS.md) is available inside it — `description`, `params`, `timeout`, `call`.
48
+
49
+ ## Stubbing an agent
50
+
51
+ ```ruby
52
+ it 'routes to the FAQ agent' do
53
+ stub_agent('FaqAgent') do # identifier defaults to 'faq_agent', as for a regular agent
54
+ model 'mock/gpt-5-mini'
55
+ instructions 'You are a stub.'
56
+ end
57
+
58
+ TriageWorkflow.new.run('What are your hours?')
59
+ end
60
+ ```
61
+
62
+ Pair it with the [Mock provider](providers/MOCK_PROVIDER.md) to queue deterministic responses.
63
+
64
+ ## Naming a stub
65
+
66
+ The first argument assigns a top-level constant, so the stub resolves the way a production class does — `Object.const_get('FaqAgent')`, a workflow runner that instantiates agents by class name, a class name stored in a row of data:
67
+
68
+ ```ruby
69
+ stub_agent('FaqAgent')
70
+
71
+ Object.const_get('FaqAgent').identifier # => 'faq_agent'
72
+ ```
73
+
74
+ The identifier is derived from the name exactly as it is for a regular agent or tool, and the block can override it like any other config. Skip the name and set the identifier in the block when nothing under test needs a constant, or when the identifier can't derive from one:
75
+
76
+ ```ruby
77
+ stub_tool { identifier 'kb-search' }
78
+ ```
79
+
80
+ A stub needs a name, an identifier set in its block, or both; `stub_tool` with neither raises `Riffer::ArgumentError`. So does a name that isn't a simple top-level constant name (`'Legacy::Agent'` and `'legacy_agent'` are both rejected — namespaced stubs aren't supported), and so does a name that is **already defined**: riffer adds constants, it never replaces them. Use your framework's `stub_const` when you need a real class swapped out for the duration of a test.
81
+
82
+ `reset!`, and the adapters that call it, remove the constants the stubs created along with their registrations.
83
+
84
+ ## Stubbing under an intermediate base class
85
+
86
+ Both helpers take a `base:` — pass your app's intermediate class so the stub lands in the registry the code under test reads:
87
+
88
+ ```ruby
89
+ stub_tool('KbSearch', base: ApplicationTool)
90
+ ```
91
+
92
+ The stub is always a **direct** subclass of `base`, mirroring how implicit registration works.
93
+
94
+ ## Cleanup
95
+
96
+ `Riffer::Testing.reset!` removes every stub built since the last reset, newest first, and forgets them. The adapters call it after each example; a no-op when nothing was stubbed.
97
+
98
+ Without an adapter — a framework riffer ships no wiring for, or a suite that configures its own hooks — include the module and call `reset!` from your own teardown:
99
+
100
+ ```ruby
101
+ class MyTestCase < WhateverBase
102
+ include Riffer::Testing
103
+
104
+ def teardown
105
+ Riffer::Testing.reset!
106
+ super
107
+ end
108
+ end
109
+ ```
110
+
111
+ Tracking lives on `Riffer::Testing` itself, so `Riffer::Testing.stub_tool(...)` outside an example and `stub_tool(...)` inside one share one list. Tracking is not synchronized — stub from a single-threaded test, before concurrent lookups begin.
112
+
113
+ ## When a stub leaks
114
+
115
+ Stubbing an identifier that is already taken raises `Riffer::DuplicateIdentifierError`:
116
+
117
+ ```ruby
118
+ stub_tool { identifier 'kb_search' }
119
+ stub_tool { identifier 'kb_search' }
120
+ # => Riffer::DuplicateIdentifierError: Duplicate identifier "kb_search" for ...
121
+ ```
122
+
123
+ Seeing this on the **first** stub in a test means an earlier stub was never removed — usually a missing adapter require in the helper, or a teardown that skips `Riffer::Testing.reset!`. The same error fires when a stub collides with a real class in your app that already claims the identifier; rename the stub or stub under an intermediate `base:`.
124
+
125
+ A leaked **named** stub leaves its constant behind on top of its registration, and only `reset!` takes a constant back down — so the second test to name it gets a `Riffer::ArgumentError` about the constant rather than a fresh class.
126
+
127
+ ## Registering without a stub
128
+
129
+ For production wiring, or a test that needs a class registered outside the stub lifecycle, `Riffer::Tool.register` / `unregister` (and the `Riffer::Agent` equivalents) manage the registry by hand. See [Registering a tool explicitly](TOOLS.md#registering-a-tool-explicitly).
data/docs/TOOLS.md CHANGED
@@ -154,11 +154,32 @@ Riffer::Tool.find('missing') # => nil
154
154
  Riffer::Tool.all # => [SearchTool, ...]
155
155
  ```
156
156
 
157
- Only **named direct subclasses** are found:
157
+ Only **named direct subclasses** are found implicitly:
158
158
 
159
159
  - Grandchildren are not visible to a grandparent's `find` or `all`. If your app defines an intermediate base class (`class ApplicationTool < Riffer::Tool`), call `find`/`all` on the intermediate class to look up its subclasses.
160
- - Anonymous classes (`Class.new(Riffer::Tool)`) are never findable, even when they set an explicit `identifier`.
160
+ - Anonymous classes (`Class.new(Riffer::Tool)`) are never findable implicitly, even when they set an explicit `identifier` — see [Registering a tool explicitly](#registering-a-tool-explicitly).
161
161
  - Two subclasses sharing an identifier raise `Riffer::DuplicateIdentifierError` at the first lookup.
162
+ - A subclass whose constant no longer points at it — after a Zeitwerk reload or an RSpec `stub_const` — drops out of `find` and `all`. The check runs when the registry is rebuilt, which defining, registering, or unregistering a subclass triggers; removing or restoring a constant on its own does not, so lookups keep returning the old class until the next rebuild.
163
+
164
+ ### Registering a tool explicitly
165
+
166
+ **Testing a tool that gets resolved by identifier? Use [`Riffer::Testing`](TESTING.md)** — `stub_tool` builds and registers a throwaway tool and cleans it up for you. The API below is the manual alternative, for production wiring and anything outside the stub lifecycle.
167
+
168
+ `register` adds a tool to its parent's registry by hand and `unregister` removes it:
169
+
170
+ ```ruby
171
+ Riffer::Tool.register(tool) # findable until unregistered
172
+ Riffer::Tool.unregister(tool) # no-op if it was never registered
173
+ ```
174
+
175
+ Explicit registration differs from implicit in a few ways:
176
+
177
+ - Anonymous classes are allowed, as long as they declare an `identifier`. A blank identifier raises `Riffer::ArgumentError`.
178
+ - The class must be a **direct** subclass of the receiver, mirroring implicit registration. `Riffer::Tool.register(SomeAppTool)` raises `Riffer::ArgumentError` when `SomeAppTool` descends from an intermediate base — call `register` on that base instead.
179
+ - Taking an identifier already held by another tool, implicit or explicit, raises `Riffer::DuplicateIdentifierError`. Re-registering the same class raises too; there is no idempotent path.
180
+ - The registration survives until you remove it — it is never dropped for a stale constant.
181
+
182
+ Registration is not synchronized. Register during boot or from a single-threaded test, before concurrent lookups begin.
162
183
 
163
184
  ## The call Method
164
185
 
@@ -163,6 +163,23 @@ class AWSAgent < Riffer::Agent
163
163
  end
164
164
  ```
165
165
 
166
+ ## File Support
167
+
168
+ Bedrock accepts file attachments either as raw bytes, or as `s3://` URIs passed straight through to Converse — Bedrock fetches the S3 object itself:
169
+
170
+ ```ruby
171
+ file = Riffer::Messages::FilePart.from_url("s3://my-bucket/document.pdf", media_type: "application/pdf")
172
+ response = provider.generate_text(
173
+ prompt: "Summarize this document",
174
+ model: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
175
+ files: [file]
176
+ )
177
+ ```
178
+
179
+ Any other URL scheme (e.g. `https://`) isn't something Bedrock accepts as a reference, so riffer downloads the file itself and sends the raw bytes — see [File Downloads](../CONFIGURATION.md#file-downloads) for the `allow_downloads` policy this requires.
180
+
181
+ **A `sha256:` on an `s3://` `FilePart` is a combination that always fails with the default setup.** Setting `sha256:` forces riffer to download and verify the file itself before Bedrock ever sees it, regardless of the URL scheme — but riffer's default downloader only fetches `https://` URLs, so an `s3://` source can never be verified out of the box. Either configure a custom `Riffer.config.files.downloader` that can reach S3, or omit `sha256:` and let the `s3://` URI pass straight through to Bedrock unverified.
182
+
166
183
  ## Message Format
167
184
 
168
185
  The provider converts Riffer messages to Bedrock format:
@@ -145,7 +145,7 @@ response = provider.generate_text(
145
145
 
146
146
  ### File Support
147
147
 
148
- Gemini supports inline base64-encoded files (images and documents):
148
+ Gemini's API only accepts inline base64-encoded files (images and documents), never a URL reference:
149
149
 
150
150
  ```ruby
151
151
  file = Riffer::Messages::FilePart.new(data: base64_data, media_type: "image/png")
@@ -156,10 +156,9 @@ response = provider.generate_text(
156
156
  )
157
157
  ```
158
158
 
159
- **Note:** URL-based file references are not supported. Provide base64-encoded data instead.
159
+ A `FilePart.from_url` source works too riffer downloads and base64-encodes it before sending, subject to the `allow_downloads` policy in [File Downloads](../CONFIGURATION.md#file-downloads).
160
160
 
161
161
  ## Limitations
162
162
 
163
163
  - **No web search** - Gemini's standard API does not include a web search tool
164
- - **No URL files** - Only base64 inline data is supported for file attachments
165
164
  - **Tool call IDs** - Gemini does not return unique call IDs for tool invocations; IDs are generated client-side
@@ -48,6 +48,9 @@ groups:
48
48
  - source: EVALS.md
49
49
  slug: evals
50
50
  description: Evaluating agent quality
51
+ - source: TESTING.md
52
+ slug: testing
53
+ description: Stubbing agents and tools in tests
51
54
  - source: GUARDRAILS.md
52
55
  slug: guardrails
53
56
  description: Input/output validation
data/lib/riffer/config.rb CHANGED
@@ -115,6 +115,97 @@ class Riffer::Config
115
115
  end
116
116
  end
117
117
 
118
+ # File-attachment-download policy for +Riffer::Messages::FilePart+ URL sources
119
+ class Files
120
+ # Allow file attachments to be downloaded to send to providers.
121
+ attr_reader :allow_downloads #: bool
122
+ # Maximum file size to download before failing.
123
+ attr_reader :max_bytes #: Integer
124
+ # Maximum amount of time to spend downloading a file before failing.
125
+ attr_reader :timeout #: Integer
126
+ # Maximum number of files to include in an individual message.
127
+ attr_reader :max_per_message #: Integer?
128
+ # Execution pattern for downloading files.
129
+ attr_reader :runner #: Riffer::Runner
130
+ # The object used to fetch a URL source's bytes
131
+ attr_reader :downloader #: untyped
132
+
133
+ #--
134
+ #: () -> void
135
+ def initialize
136
+ @allow_downloads = false
137
+ @max_bytes = 3_500_000
138
+ @timeout = 60
139
+ @max_per_message = nil
140
+ @runner = Riffer::Runner::Sequential.new
141
+ @downloader = Riffer::Files::Downloader.new
142
+ end
143
+
144
+ # Sets the allow_downloads flag, coercing boolean-ish values so an env-var
145
+ # +"false"+ (truthy in Ruby) doesn't silently enable downloads. Raises
146
+ # Riffer::ArgumentError on an unrecognized value.
147
+ #--
148
+ #: (untyped) -> void
149
+ def allow_downloads=(value)
150
+ @allow_downloads = Riffer::Helpers::Boolean.coerce(value, attribute: "allow_downloads")
151
+ end
152
+
153
+ # Sets max_bytes, provided value is a positive integer.
154
+ # Raises Riffer::ArgumentError if value is not an Integer or less than or equal to 0.
155
+ #--
156
+ #: (untyped) -> void
157
+ def max_bytes=(value)
158
+ raise Riffer::ArgumentError, "max_bytes must be a positive integer" unless value.is_a?(Integer) && value.positive?
159
+
160
+ @max_bytes = value
161
+ end
162
+
163
+ # Sets timeout, provided value is a positive integer.
164
+ # Raises Riffer::ArgumentError if value is not an Integer or is less than or equal to 0.
165
+ #--
166
+ #: (untyped) -> void
167
+ def timeout=(value)
168
+ raise Riffer::ArgumentError, "timeout must be a positive integer" unless value.is_a?(Integer) && value.positive?
169
+
170
+ @timeout = value
171
+ end
172
+
173
+ # Sets max_per_message, provided value is either nil or a positive integer.
174
+ # Raises Riffer::ArgumentError if value is not an Integer or nil, or is less than or equal to 0.
175
+ #--
176
+ #: (untyped) -> void
177
+ def max_per_message=(value)
178
+ if value.is_a?(Integer) && value.positive?
179
+ @max_per_message = value
180
+ elsif value.nil?
181
+ @max_per_message = nil
182
+ else
183
+ raise Riffer::ArgumentError, "max_per_message must be a positive integer or nil"
184
+ end
185
+ end
186
+
187
+ # Sets the runner used to process file downloads, provided value is a Riffer::Runner.
188
+ # Raises Riffer::ArgumentError if value is not a Riffer::Runner.
189
+ #--
190
+ #: (untyped) -> void
191
+ def runner=(value)
192
+ valid = value.is_a?(Riffer::Runner)
193
+ raise Riffer::ArgumentError, "runner must be a Riffer::Runner instance" unless valid
194
+
195
+ @runner = value
196
+ end
197
+
198
+ # Sets the object used to download bytes from a URL.
199
+ # Raises Riffer::ArgumentError if value does not respond to +#call+.
200
+ #--
201
+ #: (untyped) -> void
202
+ def downloader=(value)
203
+ raise Riffer::ArgumentError, "downloader must respond to #call" unless value.respond_to?(:call)
204
+
205
+ @downloader = value
206
+ end
207
+ end
208
+
118
209
  # Consumer-configured token pricing, keyed by +provider/model+ id. Riffer
119
210
  # ships no price table, so an unconfigured model carries no cost.
120
211
  class Pricing
@@ -288,6 +379,8 @@ class Riffer::Config
288
379
  # Tracing-related global configuration.
289
380
  attr_reader :tracing #: Riffer::Config::Tracing
290
381
 
382
+ attr_reader :files #: Riffer::Config::Files
383
+
291
384
  # Consumer-configured per-model token pricing.
292
385
  attr_reader :pricing #: Riffer::Config::Pricing
293
386
 
@@ -336,6 +429,7 @@ class Riffer::Config
336
429
  @tool_runtime = Riffer::Tools::Runtime::Inline.new
337
430
  @skills = Skills.new
338
431
  @tracing = Tracing.new
432
+ @files = Files.new
339
433
  @pricing = Pricing.new
340
434
  @message_id_strategy = :none
341
435
  @experimental_history_healing = false
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ class Riffer::Files::Downloader
8
+ MAX_REDIRECTS = 3 #: Integer
9
+
10
+ #: (String, max_bytes: Integer, timeout: Integer) -> String
11
+ def call(url, max_bytes:, timeout:)
12
+ fetch(url, max_bytes: max_bytes, timeout: timeout, redirects_remaining: MAX_REDIRECTS)
13
+ rescue Riffer::Error
14
+ raise
15
+ rescue StandardError => e
16
+ raise Riffer::FileDownloadError, "Error downloading file: #{e.message}"
17
+ end
18
+
19
+ private
20
+
21
+ #: (String, max_bytes: Integer, timeout: Integer, redirects_remaining: Integer) -> String
22
+ def fetch(url, max_bytes:, timeout:, redirects_remaining:)
23
+ uri = begin
24
+ URI.parse(url) #: URI::HTTPS
25
+ rescue URI::InvalidURIError => e
26
+ raise Riffer::FileDownloadError, "Invalid file URL: #{e.message}"
27
+ end
28
+ raise Riffer::FileDownloadError, "Invalid file URL: missing host" if uri.host.nil?
29
+ raise Riffer::FileDownloadError, "Unsupported URL scheme: #{uri.scheme}" unless uri.scheme == "https"
30
+
31
+ http = Net::HTTP.new(uri.host, uri.port)
32
+ http.use_ssl = true
33
+ http.open_timeout = timeout
34
+ http.read_timeout = timeout
35
+
36
+ # request_get without a block reads (and discards our chance to cap) the
37
+ # whole body before returning; the cap/read has to happen inside the
38
+ # block it yields to, where the body hasn't been consumed yet.
39
+ redirect_location = nil #: String?
40
+ content = nil #: String?
41
+
42
+ http.start do
43
+ http.request_get(uri.request_uri) do |response|
44
+ case response
45
+ when Net::HTTPRedirection
46
+ raise Riffer::FileDownloadError, "Too many redirects" if redirects_remaining.zero?
47
+
48
+ redirect_location = response["location"]
49
+ raise Riffer::FileDownloadError, "Redirect missing Location header" if redirect_location.nil?
50
+ when Net::HTTPSuccess
51
+ content = read_capped(response, max_bytes: max_bytes)
52
+ else
53
+ raise Riffer::FileDownloadError, "File download failed, status: #{response.code}"
54
+ end
55
+ end
56
+ end
57
+
58
+ if redirect_location
59
+ return fetch(redirect_location, max_bytes: max_bytes, timeout: timeout,
60
+ redirects_remaining: redirects_remaining - 1,)
61
+ end
62
+
63
+ content #: String
64
+ end
65
+
66
+ #: (Net::HTTPResponse, max_bytes: Integer) -> String
67
+ def read_capped(response, max_bytes:)
68
+ content_length = response["content-length"]&.to_i
69
+ raise Riffer::FileTooLargeError, "File too large" if content_length && content_length > max_bytes
70
+
71
+ buffer = +""
72
+ response.read_body do |chunk|
73
+ buffer << chunk
74
+ raise Riffer::FileTooLargeError, "File too large" if buffer.bytesize > max_bytes
75
+ end
76
+ buffer
77
+ end
78
+ end