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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +8 -0
- data/Steepfile +1 -0
- data/docs/AGENTS.md +23 -2
- data/docs/CONFIGURATION.md +32 -0
- data/docs/MESSAGES.md +10 -0
- data/docs/OVERVIEW.md +1 -0
- data/docs/TESTING.md +129 -0
- data/docs/TOOLS.md +23 -2
- data/docs/providers/AMAZON_BEDROCK.md +17 -0
- data/docs/providers/GEMINI.md +2 -3
- data/docs-site/manifest.yml +3 -0
- data/lib/riffer/config.rb +94 -0
- data/lib/riffer/files/downloader.rb +78 -0
- data/lib/riffer/files/resolver.rb +97 -0
- data/lib/riffer/files.rb +5 -0
- data/lib/riffer/helpers/identifier.rb +11 -3
- data/lib/riffer/messages/file_part.rb +61 -9
- data/lib/riffer/providers/amazon_bedrock.rb +7 -7
- data/lib/riffer/providers/base.rb +8 -0
- data/lib/riffer/providers/gemini.rb +6 -5
- data/lib/riffer/providers/open_ai.rb +6 -0
- data/lib/riffer/providers/open_router.rb +6 -0
- data/lib/riffer/registrable.rb +105 -22
- data/lib/riffer/testing/minitest.rb +21 -0
- data/lib/riffer/testing/rspec.rb +11 -0
- data/lib/riffer/testing.rb +130 -0
- data/lib/riffer/version.rb +1 -1
- data/lib/riffer.rb +31 -0
- data/sig/_private/minitest.rbs +9 -0
- data/sig/_private/riffer/testing/minitest.rbs +6 -0
- data/sig/_private/rspec.rbs +7 -0
- data/sig/_private/zeitwerk.rbs +2 -0
- data/sig/generated/riffer/config.rbs +64 -0
- data/sig/generated/riffer/files/downloader.rbs +16 -0
- data/sig/generated/riffer/files/resolver.rbs +36 -0
- data/sig/generated/riffer/files.rbs +4 -0
- data/sig/generated/riffer/helpers/identifier.rbs +8 -0
- data/sig/generated/riffer/messages/file_part.rbs +38 -6
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +4 -0
- data/sig/generated/riffer/providers/base.rbs +4 -0
- data/sig/generated/riffer/providers/gemini.rbs +4 -0
- data/sig/generated/riffer/providers/open_ai.rbs +4 -0
- data/sig/generated/riffer/providers/open_router.rbs +4 -0
- data/sig/generated/riffer/registrable.rbs +57 -9
- data/sig/generated/riffer/testing/rspec.rbs +2 -0
- data/sig/generated/riffer/testing.rbs +75 -0
- data/sig/generated/riffer.rbs +32 -0
- data/sig/manifest.yaml +1 -0
- data/sig/manual/riffer/testing.rbs +5 -0
- metadata +31 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7f7862ed670d852eee1e076386032449773a8782424fbfc9bc80e96aab61b1e3
|
|
4
|
+
data.tar.gz: b520d7629441e6acd75a677bc3d094359f5a5b7f31c2986d6aadd51f2935b3e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '069d1d96193748ffdc48eb95a8fe22b57c6293a33e7ba4658a01fda4e5c4a7847145bb234605c0507059830c300dc2d85ec295ecf2d59de20265385644d6dc89'
|
|
7
|
+
data.tar.gz: 86bbcfb5408f3b5ba0fbb327dff9c2a98c0ee410e3ce4ab72c89a3a66d8aaa8e4d095e2ee14bc32c814caa28503cf0d099095441a60eb4cb9e11b7639f522d65
|
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
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
|
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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:
|
data/docs/providers/GEMINI.md
CHANGED
|
@@ -145,7 +145,7 @@ response = provider.generate_text(
|
|
|
145
145
|
|
|
146
146
|
### File Support
|
|
147
147
|
|
|
148
|
-
Gemini
|
|
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
|
-
|
|
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
|
data/docs-site/manifest.yml
CHANGED
|
@@ -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
|