riffer 0.45.0 → 0.46.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: b166b910624129bd5b64b9443caba3b5c781b691a7453b5640cd132c21cd72c2
4
- data.tar.gz: d64178e66b49945647393430b57f84a501ec7c0bfbe866b20f6f1b6af44ef570
3
+ metadata.gz: ac304f75fface8720f1e2565ea2064b643abb2f1c29f5a9dfe5281820e9d3986
4
+ data.tar.gz: de5695cffa30697e0356de921227caffdab12ff806e143166369c36e3ae010d6
5
5
  SHA512:
6
- metadata.gz: 36ce62e6539e22a01c5f8523278203b0307a2753c8e3af8e683133b741144417b360721862249d2c64fb118211f134e7b968d681e4c31e20f9ca051768e9aef6
7
- data.tar.gz: 39ac8834695876cd923e3dffe7065045f7296e418056e18eae9da4811ba04c886b546342a4a4586d92ac529ec456a18e0b272b0598eb70c72371af2dbefebce3
6
+ metadata.gz: dfe9da5a5cb0a8a1147846766acc79b518a9f7025d1d95fbfddce3604e2315afafc04762b339879eee2db875aba5cc296056045f213f47a17cb9f730e17f3e07
7
+ data.tar.gz: b548a4b44b001b79ee295969af5a868f50d0e847a183dc9433e5b23d4b1e7c9effed3ea64a76a8f5022db959a65394d8f8ff28bba9a6d46484841322483ebe02
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.45.0"
2
+ ".": "0.46.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ 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.46.0](https://github.com/janeapp/riffer/compare/riffer/v0.45.0...riffer/v0.46.0) (2026-09-08)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * Response#blocked?, Response#interrupted?, and Response#interrupt_reason are removed. Check response.outcome.reason for :guardrail_blocked, :interrupted, or :max_steps instead; response.tripwire still carries the full tripwire object. Response.new now requires outcome:.
14
+
15
+ ### Features
16
+
17
+ * report how a run ended through Response#outcome ([#425](https://github.com/janeapp/riffer/issues/425)) ([2ba2157](https://github.com/janeapp/riffer/commit/2ba2157668cd4a2b013da190dd647ae131accfa3))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **anthropic:** merge caller output_config with structured output ([#422](https://github.com/janeapp/riffer/issues/422)) ([e1a60e6](https://github.com/janeapp/riffer/commit/e1a60e6f082dbf7d4ee9d151e96e56bdf3f26359))
23
+ * **runner:** make Fibers runner safe inside a running Async reactor ([#424](https://github.com/janeapp/riffer/issues/424)) ([47d3706](https://github.com/janeapp/riffer/commit/47d3706dbeac1e456bbf8365361cf16741b09adf))
24
+
8
25
  ## [0.45.0](https://github.com/janeapp/riffer/compare/riffer/v0.44.0...riffer/v0.45.0) (2026-09-03)
9
26
 
10
27
 
data/docs/AGENTS.md CHANGED
@@ -168,6 +168,19 @@ end
168
168
 
169
169
  The LLM response is automatically parsed and validated against the schema. Access the result via `response.structured_output`.
170
170
 
171
+ When the response is not valid JSON or does not satisfy the schema, `response.structured_output` is `nil`, `response.outcome.reason` is `:invalid_structured_output`, and `response.outcome.detail` carries the parse or validation message:
172
+
173
+ ```ruby
174
+ response = SentimentAgent.generate('Analyze: "I love this!"')
175
+
176
+ if response.outcome.success?
177
+ response.structured_output # => {sentiment: "positive", score: 0.95}
178
+ else
179
+ response.outcome.reason # => :invalid_structured_output
180
+ response.outcome.detail # => "score is required"
181
+ end
182
+ ```
183
+
171
184
  #### Nested Objects
172
185
 
173
186
  Use `Hash` with a block to define nested object schemas:
@@ -30,9 +30,8 @@ agent.generate(prompt = nil, files: nil)
30
30
  ```ruby
31
31
  # New conversation (class method — recommended for simple calls)
32
32
  response = MyAgent.generate('Hello', context: {user_id: 123})
33
- puts response.content # Access the response text
34
- puts response.blocked? # Check if guardrail blocked (always false without guardrails)
35
- puts response.interrupted? # Check if a callback interrupted the loop
33
+ puts response.content # Access the response text
34
+ puts response.outcome.reason # How the run ended (:completed, :guardrail_blocked, :interrupted, ...)
36
35
 
37
36
  # New conversation (instance method — when you need message history or callbacks)
38
37
  agent = MyAgent.new(context: {user_id: 123})
@@ -142,9 +141,9 @@ Works with both `generate` and `stream`. Only emits agent-generated messages (As
142
141
 
143
142
  Callbacks can interrupt the agent loop. This is useful for human-in-the-loop approval, cost limits, or content filtering.
144
143
 
145
- Use `agent.interrupt!` (or the lower-level `throw :riffer_interrupt`) to stop the loop. The response will have `interrupted?` set to `true` and contain the accumulated content up to the point of interruption.
144
+ Use `agent.interrupt!` (or the lower-level `throw :riffer_interrupt`) to stop the loop. The response's `outcome.reason` will be `:interrupted` and `content` will hold the accumulated content up to the point of interruption.
146
145
 
147
- An optional reason can be passed to `interrupt!`. It is available via `interrupt_reason` on the response (generate) or `reason` on the `Interrupt` event (stream):
146
+ An optional reason can be passed to `interrupt!`. It is available via `outcome.detail` on the response (generate) or `reason` on the `Interrupt` event (stream):
148
147
 
149
148
  ```ruby
150
149
  agent = MyAgent.new
@@ -155,9 +154,9 @@ agent.session.on_message do |msg|
155
154
  end
156
155
 
157
156
  response = agent.generate('Call the tool')
158
- response.interrupted? # => true
159
- response.interrupt_reason # => "needs human approval"
160
- response.content # => last assistant content before interrupt
157
+ response.outcome.reason # => :interrupted
158
+ response.outcome.detail # => "needs human approval"
159
+ response.content # => last assistant content before interrupt
161
160
  ```
162
161
 
163
162
  **Streaming** — interrupts emit an `Interrupt` event:
@@ -188,7 +187,7 @@ agent.session.on_message { |msg| throw :riffer_interrupt if needs_approval?(msg)
188
187
 
189
188
  response = agent.generate('Do something risky')
190
189
 
191
- if response.interrupted?
190
+ if response.outcome.reason == :interrupted
192
191
  approve_action(agent.session.messages)
193
192
  response = agent.generate('Approved, go ahead') # executes pending tools, then calls the LLM
194
193
  # or: agent.generate # resume without a new turn
@@ -301,23 +300,49 @@ agent.context[:skills] # the Skills::Context, if skills configured
301
300
 
302
301
  ## Response Attributes
303
302
 
304
- `Riffer::Agent::Response` is returned by `generate`:
303
+ `Riffer::Agent::Response` is returned by `generate`. Start with `response.outcome`: its `reason` says how the run ended, and everything else on the response is detail for that reason. `response.content` and `response.structured_output` are only meaningful when the reason is `:completed`; see [response.outcome](#responseoutcome) for the full vocabulary.
305
304
 
306
305
  | Attribute | Type | Description |
307
306
  | ---------------------- | --------------------------- | ------------------------------------------------------------------------------------------------ |
308
307
  | `content` | `String` | The response text |
308
+ | `outcome` | `Outcome` | How the run ended — `reason` and optional `detail` (see below) |
309
309
  | `structured_output` | `Hash` / `nil` | Parsed and validated structured output (see below) |
310
- | `blocked?` | `Boolean` | `true` if a guardrail tripwire fired |
311
310
  | `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
312
311
  | `modified?` | `Boolean` | `true` if a guardrail modified the content |
313
312
  | `modifications` | `Array` | List of guardrail modifications applied |
314
- | `interrupted?` | `Boolean` | `true` if the loop was interrupted |
315
- | `interrupt_reason` | `String` / `Symbol` / `nil` | The reason passed to `throw :riffer_interrupt` |
316
313
  | `messages` | `Array` | Full message history from the conversation |
317
314
  | `healed_tool_call_ids` | `Array[String]` | `tool_call` ids filled with placeholder results during interrupt healing (else `[]`) |
318
315
  | `token_usage` | `TokenUsage` / `nil` | Aggregate `Riffer::Providers::TokenUsage` across this run's LLM calls (`nil` when none reported) |
319
316
  | `steps` | `Integer` | LLM calls made during this run (`0` when a before-guardrail blocks first); not the session's cumulative count |
320
317
 
318
+ ### response.outcome
319
+
320
+ `response.outcome` is a `Riffer::Agent::Outcome` — the single place to read how the run ended. `reason` is always one of the values below; `detail` is a `String` with the specifics when there are any, else `nil`. `outcome.success?` is shorthand for `reason == :completed`.
321
+
322
+ | Reason | Source | `detail` |
323
+ | ---------------------------- | ---------------------------------------------------------- | ----------------------------------------- |
324
+ | `:completed` | The loop ended normally | `nil` |
325
+ | `:guardrail_blocked` | A guardrail tripwire fired (`tripwire` is set) | The tripwire reason |
326
+ | `:max_steps` | The `max_steps` limit was reached | `nil` |
327
+ | `:interrupted` | A callback called `interrupt!` / `throw :riffer_interrupt` | The interrupt reason, or `nil` |
328
+ | `:length`, `:content_filter`, `:context_window`, `:malformed_output`, `:error`, `:other` | The assistant message's normalized `finish_reason` (see [Messages — Finish Reasons](MESSAGES.md#finish-reasons)) | The provider's raw finish value (`finish_reason_raw`), or `nil` |
329
+ | `:invalid_structured_output` | The final message failed JSON parsing or schema validation | The parse or validation error |
330
+
331
+ When several apply, the most causal wins: a guardrail block outranks an interrupt, an interrupt outranks the provider's finish reason, and the provider's finish reason outranks a structured output failure. A run that hit `:length` and therefore produced invalid JSON reports `:length`.
332
+
333
+ ```ruby
334
+ agent = MyAgent.new
335
+ response = agent.generate('Hello')
336
+
337
+ case response.outcome.reason
338
+ when :completed then puts response.content
339
+ when :guardrail_blocked then puts "Blocked: #{response.outcome.detail}"
340
+ when :interrupted, :max_steps then response = agent.generate('Continue')
341
+ when :invalid_structured_output then warn response.outcome.detail
342
+ else warn "Provider stopped early: #{response.outcome.reason}"
343
+ end
344
+ ```
345
+
321
346
  ### response.structured_output
322
347
 
323
348
  When structured output is configured, the LLM response is parsed as JSON and validated against the schema. The validated result is available as `response.structured_output`:
@@ -328,7 +353,7 @@ response.content # => raw JSON string from the LLM
328
353
  response.structured_output # => {sentiment: "positive", score: 0.95}
329
354
  ```
330
355
 
331
- Returns `nil` when structured output is not configured or when validation fails.
356
+ Returns `nil` when structured output is not configured, or when parsing or validation fails — in which case `response.outcome.reason` is `:invalid_structured_output` and `response.outcome.detail` carries the error.
332
357
 
333
358
  The assistant message in the message history stores the parsed hash, so you can access structured output directly from persisted messages:
334
359
 
data/docs/AGENT_LOOP.md CHANGED
@@ -34,7 +34,7 @@ The agent loop normally runs until the LLM produces a response with no tool call
34
34
  Guardrails are registered at class definition time and run automatically on every request. When a guardrail calls `block`, it sets a **tripwire** that stops the loop immediately. The LLM is never called (for `:before` guardrails) or its response is discarded (for `:after` guardrails).
35
35
 
36
36
  - **When to use:** Policy enforcement that should always apply — content filtering, input validation, length limits.
37
- - **Response:** `response.blocked?` returns `true`, `response.tripwire` contains the reason and metadata.
37
+ - **Response:** `response.outcome.reason` is `:guardrail_blocked`, `response.tripwire` contains the reason and metadata.
38
38
  - **Streaming:** Yields a `GuardrailTripwire` event.
39
39
  - **Resumable:** No. A tripwire is a hard stop. The caller must change the input and start a new `generate`/`stream` call.
40
40
 
@@ -45,7 +45,7 @@ class MyAgent < Riffer::Agent
45
45
  end
46
46
 
47
47
  response = MyAgent.generate('blocked input')
48
- response.blocked? # => true
48
+ response.outcome.reason # => :guardrail_blocked
49
49
  response.tripwire.reason # => "Content policy violation"
50
50
  ```
51
51
 
@@ -54,7 +54,7 @@ response.tripwire.reason # => "Content policy violation"
54
54
  Callbacks registered with `on_message` can call `agent.interrupt!` (or `throw :riffer_interrupt`) to pause the loop at any point — after receiving an assistant message, after a tool result, etc. The caller controls exactly when and why to interrupt.
55
55
 
56
56
  - **When to use:** Flow control that depends on runtime decisions — human-in-the-loop approval, budget tracking, conditional pausing.
57
- - **Response:** `response.interrupted?` returns `true`, `response.interrupt_reason` contains the optional reason.
57
+ - **Response:** `response.outcome.reason` is `:interrupted`, `response.outcome.detail` contains the optional reason.
58
58
  - **Streaming:** Yields an `Interrupt` event with a `reason` attribute.
59
59
  - **Resumable:** Yes. Call `generate('Continue')` or `stream('Continue')` on the same agent instance to resume. For cross-process resume, pass persisted messages as an array to a new agent. Pending tool calls are automatically executed before the LLM loop resumes.
60
60
 
@@ -65,8 +65,8 @@ agent.session.on_message do |msg|
65
65
  end
66
66
 
67
67
  response = agent.generate('Do something risky')
68
- response.interrupted? # => true
69
- response.interrupt_reason # => "approval needed"
68
+ response.outcome.reason # => :interrupted
69
+ response.outcome.detail # => "approval needed"
70
70
  response = agent.generate('Approved, continue') # continues where it left off
71
71
  ```
72
72
 
@@ -75,7 +75,7 @@ response = agent.generate('Approved, continue') # continues where it left off
75
75
  The `max_steps` class method caps the number of LLM call steps in the tool-use loop. When the step count reaches the limit, the loop interrupts automatically with reason `:max_steps`.
76
76
 
77
77
  - **When to use:** Safety net to prevent runaway tool-use loops — useful when agents have access to many tools or operate autonomously.
78
- - **Response:** `response.interrupted?` returns `true`, `response.interrupt_reason` is `:max_steps`.
78
+ - **Response:** `response.outcome.reason` is `:max_steps`.
79
79
  - **Streaming:** Yields an `Interrupt` event with `reason: :max_steps`.
80
80
  - **Resumable:** Yes. Call `generate('Continue')` or `stream('Continue')` on the same agent instance to resume. For cross-process resume, pass persisted messages as an array to a new agent. Pending tool calls are automatically executed before the LLM loop resumes.
81
81
 
@@ -86,8 +86,7 @@ class MyAgent < Riffer::Agent
86
86
  end
87
87
 
88
88
  response = MyAgent.generate('Do a complex task')
89
- response.interrupted? # => true (if 8 steps were reached)
90
- response.interrupt_reason # => :max_steps
89
+ response.outcome.reason # => :max_steps (if 8 steps were reached)
91
90
  ```
92
91
 
93
92
  ### Unhandled Exceptions
@@ -101,6 +100,6 @@ If a guardrail, provider call, or other internal code raises an exception, it pr
101
100
  | Defined | At class level (`guardrail :before`) | At instance level (`on_message`) | At class level (`max_steps 8`) |
102
101
  | Fires | Automatically on every request | When callback logic decides | When step count reaches limit |
103
102
  | Resumable | No | Yes (call `generate`/`stream` again) | Yes (call `generate`/`stream` again) |
104
- | Response flag | `blocked?` | `interrupted?` | `interrupted?` |
103
+ | Outcome reason | `:guardrail_blocked` | `:interrupted` | `:max_steps` |
105
104
  | Stream event | `GuardrailTripwire` | `Interrupt` | `Interrupt` |
106
105
  | Purpose | Policy enforcement | Flow control | Runaway loop prevention |
@@ -198,19 +198,19 @@ Per file, riffer applies this policy, in order:
198
198
 
199
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
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).
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
202
  4. **Provider needs the bytes inline** — riffer downloads the file, verifying `sha256:` if given.
203
203
 
204
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
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://`). |
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
214
 
215
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
216
 
@@ -345,14 +345,15 @@ end
345
345
 
346
346
  ### Anthropic
347
347
 
348
- | Option | Description |
349
- | ------------- | ------------------------------------------- |
350
- | `temperature` | Sampling temperature |
351
- | `max_tokens` | Maximum tokens in response |
352
- | `top_p` | Nucleus sampling parameter |
353
- | `top_k` | Top-k sampling parameter |
354
- | `thinking` | Extended thinking config hash (Claude 3.7+) |
355
- | `web_search` | Enable web search (`true` or config hash) |
348
+ | Option | Description |
349
+ | --------------- | ------------------------------------------- |
350
+ | `temperature` | Sampling temperature |
351
+ | `max_tokens` | Maximum tokens in response |
352
+ | `top_p` | Nucleus sampling parameter |
353
+ | `top_k` | Top-k sampling parameter |
354
+ | `thinking` | Extended thinking config hash (Claude 3.7+) |
355
+ | `output_config` | Output config hash (e.g. `effort`) |
356
+ | `web_search` | Enable web search (`true` or config hash) |
356
357
 
357
358
  ```ruby
358
359
  class MyAgent < Riffer::Agent
@@ -365,6 +366,12 @@ class ReasoningAgent < Riffer::Agent
365
366
  model 'anthropic/claude-haiku-4-5-20251001'
366
367
  model_options thinking: {type: "enabled", budget_tokens: 10000}
367
368
  end
369
+
370
+ # With an output effort level
371
+ class EffortAgent < Riffer::Agent
372
+ model 'anthropic/claude-opus-5'
373
+ model_options output_config: {effort: "high"}
374
+ end
368
375
  ```
369
376
 
370
377
  ## Environment Variables
data/docs/GUARDRAILS.md CHANGED
@@ -28,10 +28,10 @@ class MyAgent < Riffer::Agent
28
28
  end
29
29
 
30
30
  response = MyAgent.generate("Hello!")
31
- response.blocked? # => false
31
+ response.outcome.reason # => :completed
32
32
 
33
33
  response = MyAgent.generate("You are a badword")
34
- response.blocked? # => true
34
+ response.outcome.reason # => :guardrail_blocked
35
35
  response.tripwire.reason # => "Profanity detected"
36
36
  ```
37
37
 
@@ -195,7 +195,7 @@ end
195
195
  response = MyAgent.generate("Hello")
196
196
 
197
197
  response.content # The response text
198
- response.blocked? # true if a guardrail blocked execution
198
+ response.outcome.reason # :guardrail_blocked if a guardrail blocked execution
199
199
  response.tripwire # Tripwire object with block details (if blocked)
200
200
  response.modified? # true if any guardrail transformed data
201
201
  response.modifications # Array of Modification records
@@ -206,7 +206,7 @@ response.modifications # Array of Modification records
206
206
  ```ruby
207
207
  response = MyAgent.generate("Hello")
208
208
 
209
- if response.blocked?
209
+ if response.outcome.reason == :guardrail_blocked
210
210
  puts "Blocked: #{response.tripwire.reason}"
211
211
  puts "Phase: #{response.tripwire.phase}"
212
212
  puts "Guardrail: #{response.tripwire.guardrail}"
data/docs/MESSAGES.md CHANGED
@@ -49,7 +49,8 @@ msg.role # => :assistant
49
49
  msg.content # => "I'm doing well, thank you!"
50
50
  msg.tool_calls # => []
51
51
  msg.token_usage # => nil or Riffer::Providers::TokenUsage
52
- msg.finish_reason # => nil or a normalized Symbol (see below)
52
+ msg.finish_reason # => nil or a normalized Symbol (see below)
53
+ msg.finish_reason_raw # => nil or the provider's raw wire value (e.g. "max_tokens")
53
54
 
54
55
  # Response with tool calls
55
56
  msg = Riffer::Messages::Assistant.new("", tool_calls: [
@@ -94,7 +95,7 @@ The cache buckets are subsets of `input_tokens`, never additions to it — summi
94
95
  | `:error` | The provider reported an error finish. |
95
96
  | `:other` | A provider-specific value with no normalized equivalent. |
96
97
 
97
- `finish_reason` is `nil` when the provider doesn't report one. The provider's raw wire value travels alongside on the `FinishReasonDone` stream event and the `riffer.finish_reason.raw` trace attribute — for OpenRouter that is the upstream model's `native_finish_reason`, and for a failed OpenAI response it is the error code. Use `finish_reason` to detect truncation without parsing provider responses:
98
+ `finish_reason` is `nil` when the provider doesn't report one. The provider's raw wire value travels alongside as `finish_reason_raw` on the message (round-tripped through `to_h` / `from_hash`), on the `FinishReasonDone` stream event, and as the `riffer.finish_reason.raw` trace attribute — for OpenRouter that is the upstream model's `native_finish_reason`, and for a failed OpenAI response it is the error code. Use `finish_reason` to detect truncation without parsing provider responses:
98
99
 
99
100
  ```ruby
100
101
  response = agent.generate("Summarize this document")
@@ -103,7 +104,7 @@ retry_with_higher_limit if agent.session.messages.last.finish_reason == :length
103
104
 
104
105
  #### Structured Output on Messages
105
106
 
106
- When an agent has `structured_output` configured, the final assistant message stores the parsed hash directly. The `structured_output?` predicate checks for a non-nil value:
107
+ When an agent has `structured_output` configured, the final assistant message stores the parsed hash directly. The message holds the parsed JSON, not the schema-validated result; schema validation is reported on `response.outcome` (see [Agent Lifecycle — response.outcome](AGENT_LIFECYCLE.md#responseoutcome)). The `structured_output?` predicate checks for a non-nil value:
107
108
 
108
109
  ```ruby
109
110
  msg = Riffer::Messages::Assistant.new('{"sentiment":"positive"}', structured_output: {sentiment: "positive"})
@@ -215,7 +215,7 @@ Emitted when the agent loop is interrupted. This can happen in two ways:
215
215
  - An `on_message` callback calls `agent.interrupt!` or `throw :riffer_interrupt` (reason is a String or `nil`).
216
216
  - The `max_steps` limit is reached (reason is the Symbol `:max_steps`).
217
217
 
218
- This is the streaming equivalent of `Response#interrupted?` in generate mode.
218
+ This is the streaming equivalent of `response.outcome.reason == :interrupted` (or `:max_steps`) in generate mode.
219
219
 
220
220
  ```ruby
221
221
  # Callback interrupt with a string reason
@@ -220,6 +220,8 @@ end
220
220
 
221
221
  Fibers use cooperative scheduling — they yield control at I/O boundaries (network calls, file reads, sleep). CPU-bound tools will not benefit from the fibers runtime. Be mindful of fiber-local state (`Fiber.[]`) and note that `Thread.current[]` values are shared across all fibers in the same thread.
222
222
 
223
+ The fibers runtime is safe to use inside an existing reactor. When called from plain Ruby it starts its own reactor and blocks until every tool call finishes. When the process is already inside an Async task — for example under Falcon, or inside an `Async do ... end` block — it joins the current task instead of starting a nested reactor, so it is safe to call from request handlers.
224
+
223
225
  ### Custom Runtimes
224
226
 
225
227
  Create a custom runtime by subclassing `Riffer::Tools::Runtime` and overriding the private `dispatch_tool_call` method:
data/docs/TRACING.md CHANGED
@@ -113,6 +113,8 @@ Any tags passed to `#generate` / `#stream` via `tags:` are stamped on **all four
113
113
  | `gen_ai.usage.cache_read.input_tokens` | int | When the provider reported cache reads |
114
114
  | `gen_ai.usage.cache_creation.input_tokens` | int | When the provider reported cache writes |
115
115
  | `riffer.cost` | float | When every call in the run was priced |
116
+ | `riffer.outcome.reason` | string | Always — the `response.outcome.reason` |
117
+ | `riffer.outcome.detail` | string | When `response.outcome.detail` is present |
116
118
  | `riffer.interrupt.reason` | string | On interrupt (e.g. approval needed, max steps) |
117
119
  | `riffer.tripwire.guardrail` | string | On a guardrail tripwire, when the guardrail is named |
118
120
  | `riffer.tripwire.reason` | string | On a guardrail tripwire |
@@ -271,7 +273,7 @@ When enabled, content is serialized as GenAI-semconv JSON strings. File attachme
271
273
  The span and attribute shape is a public, versioned contract, in two tiers:
272
274
 
273
275
  - **`gen_ai.*`** tracks the OpenTelemetry GenAI semantic conventions, pinned to schema version `1.37.0`. That convention is still "Development" status upstream and its attribute names may change; Riffer absorbs such renames deliberately in a release, never silently, with a CHANGELOG entry.
274
- - **`riffer.*`** is Riffer-owned (`riffer.steps`, `riffer.cost`, `riffer.interrupt.reason`, `riffer.tripwire.*`, `riffer.guardrail.*`, `riffer.finish_reason.raw`) and changes only through a normal version bump and CHANGELOG entry.
276
+ - **`riffer.*`** is Riffer-owned (`riffer.steps`, `riffer.cost`, `riffer.outcome.*`, `riffer.interrupt.reason`, `riffer.tripwire.*`, `riffer.guardrail.*`, `riffer.finish_reason.raw`) and changes only through a normal version bump and CHANGELOG entry.
275
277
 
276
278
  The semantic-convention schema version is a documented pin rather than a span attribute — the OpenTelemetry Ruby API can't attach a schema URL to a tracer. The runtime version signal is the instrumentation scope: every span carries scope name `riffer` at the gem version that emitted it. Pin the Riffer version your dashboards depend on, and watch the CHANGELOG for tracing entries before upgrading.
277
279
 
@@ -21,13 +21,14 @@ Use `stub_response` to queue responses:
21
21
  ```ruby
22
22
  # Get the provider instance from the agent
23
23
  agent = TestableAgent.new
24
- provider = agent.send(:provider_instance)
24
+ provider = agent.provider
25
25
 
26
26
  # Stub a simple text response
27
27
  provider.stub_response("Hello, I'm here to help!")
28
28
 
29
29
  # Now generate will return the stubbed response
30
30
  response = agent.generate("Hi")
31
+ response.content
31
32
  # => "Hello, I'm here to help!"
32
33
  ```
33
34
 
@@ -55,10 +56,10 @@ provider.stub_response("First response")
55
56
  provider.stub_response("Second response")
56
57
  provider.stub_response("Third response")
57
58
 
58
- agent.generate("Message 1") # => "First response"
59
- agent.generate("Message 2") # => "Second response"
60
- agent.generate("Message 3") # => "Third response"
61
- agent.generate("Message 4") # => "Mock response" (default)
59
+ agent.generate("Message 1").content # => "First response"
60
+ agent.generate("Message 2").content # => "Second response"
61
+ agent.generate("Message 3").content # => "Third response"
62
+ agent.generate("Message 4").content # => "Mock response" (default)
62
63
  ```
63
64
 
64
65
  ## Inspecting Calls
@@ -92,7 +93,7 @@ require 'minitest/autorun'
92
93
  class MyAgentTest < Minitest::Test
93
94
  def setup
94
95
  @agent = TestableAgent.new
95
- @provider = @agent.send(:provider_instance)
96
+ @provider = @agent.provider
96
97
  end
97
98
 
98
99
  def test_generates_response
@@ -146,18 +147,24 @@ text_done = events.find { |e| e.is_a?(Riffer::StreamEvents::TextDone) }
146
147
 
147
148
  ## Web Search
148
149
 
149
- The test provider emits web search events when `web_search: true` is passed to the stream call:
150
+ The mock provider emits web search events when `web_search: true` is set in the agent's `model_options`:
150
151
 
151
152
  ```ruby
152
- provider.stub_response("Here are the latest results.")
153
+ class SearchingAgent < Riffer::Agent
154
+ model 'mock/any'
155
+ model_options web_search: true
156
+ end
157
+
158
+ agent = SearchingAgent.new
159
+ agent.provider.stub_response("Here are the latest results.")
153
160
 
154
161
  events = []
155
- agent.stream("What's new in Ruby?", web_search: true).each { |e| events << e }
162
+ agent.stream("What's new in Ruby?").each { |e| events << e }
156
163
 
157
164
  # Events include WebSearchStatus and WebSearchDone before text events
158
- search_deltas = events.select { |e| e.is_a?(Riffer::StreamEvents::WebSearchStatus) }
165
+ search_statuses = events.select { |e| e.is_a?(Riffer::StreamEvents::WebSearchStatus) }
159
166
  search_done = events.find { |e| e.is_a?(Riffer::StreamEvents::WebSearchDone) }
160
- search_done.query # => "test search query"
167
+ search_done.query # => "mock search query"
161
168
  search_done.sources # => [{title: "Example", url: "https://example.com"}]
162
169
  ```
163
170
 
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # How a run ended — the single place to read whether the agent completed
5
+ # normally and, if not, why. +detail+ carries the specifics when there are any:
6
+ # the tripwire reason, the interrupt reason, the provider's raw finish value,
7
+ # or the structured output parse/validation error.
8
+ #
9
+ # response = agent.generate("Analyze this")
10
+ # case response.outcome.reason
11
+ # when :completed then puts response.structured_output
12
+ # when :invalid_structured_output then warn response.outcome.detail
13
+ # end
14
+ class Riffer::Agent::Outcome
15
+ # Finish reasons that end a turn normally; every other finish reason means the
16
+ # provider cut the turn short and surfaces as the run's outcome verbatim.
17
+ NORMAL_FINISH_REASONS = %i[stop tool_calls].freeze #: Array[Symbol]
18
+
19
+ # Derived from the provider vocabulary so a new finish reason becomes an
20
+ # outcome without a second list to update.
21
+ PROVIDER_STOP_REASONS = (Riffer::Providers::FinishReason::VALUES - NORMAL_FINISH_REASONS).freeze #: Array[Symbol]
22
+
23
+ # The vocabulary every run ends in.
24
+ VALUES = (%i[completed guardrail_blocked interrupted max_steps invalid_structured_output] +
25
+ PROVIDER_STOP_REASONS).freeze #: Array[Symbol]
26
+
27
+ # Why the run ended.
28
+ attr_reader :reason #: Symbol
29
+
30
+ # Human-readable specifics for +reason+, when there are any.
31
+ attr_reader :detail #: String?
32
+
33
+ # Raises Riffer::ArgumentError when +reason+ is outside VALUES.
34
+ #--
35
+ #: (reason: Symbol, ?detail: String?) -> void
36
+ def initialize(reason:, detail: nil)
37
+ unless VALUES.include?(reason)
38
+ raise Riffer::ArgumentError, "reason must be one of #{VALUES.inspect}, got #{reason.inspect}"
39
+ end
40
+
41
+ @reason = reason
42
+ @detail = detail
43
+ end
44
+
45
+ # Returns true when the run completed normally.
46
+ #
47
+ #--
48
+ #: () -> bool
49
+ def success?
50
+ reason == :completed
51
+ end
52
+ end
@@ -1,30 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Wraps an agent generation response. When a guardrail blocks execution,
5
- # +content+ is empty and +tripwire+ carries the block details.
4
+ # Wraps an agent generation response. +outcome+ says how the run ended; when a
5
+ # guardrail blocks execution, +content+ is empty and +tripwire+ carries the
6
+ # block details.
6
7
  #
7
8
  # response = agent.generate("Hello")
8
- # if response.blocked?
9
- # puts "Blocked: #{response.tripwire.reason}"
10
- # else
9
+ # if response.outcome.success?
11
10
  # puts response.content
11
+ # else
12
+ # puts "#{response.outcome.reason}: #{response.outcome.detail}"
12
13
  # end
13
14
  class Riffer::Agent::Response
14
- # @rbs @interrupted: bool
15
-
16
15
  # The response content.
17
16
  attr_reader :content #: String
18
17
 
18
+ # How the run ended.
19
+ attr_reader :outcome #: Riffer::Agent::Outcome
20
+
19
21
  # The tripwire if execution was blocked.
20
22
  attr_reader :tripwire #: Riffer::Guardrails::Tripwire?
21
23
 
22
24
  # The modifications made by guardrails during processing.
23
25
  attr_reader :modifications #: Array[Riffer::Guardrails::Modification]
24
26
 
25
- # The reason provided with the interrupt, if any.
26
- attr_reader :interrupt_reason #: (String | Symbol)?
27
-
28
27
  # The parsed structured output, if structured output was configured.
29
28
  attr_reader :structured_output #: Hash[Symbol, untyped]?
30
29
 
@@ -45,10 +44,9 @@ class Riffer::Agent::Response
45
44
  #--
46
45
  #: (
47
46
  # String,
47
+ # outcome: Riffer::Agent::Outcome,
48
48
  # ?tripwire: Riffer::Guardrails::Tripwire?,
49
49
  # ?modifications: Array[Riffer::Guardrails::Modification],
50
- # ?interrupted: bool,
51
- # ?interrupt_reason: (String | Symbol)?,
52
50
  # ?structured_output: Hash[Symbol, untyped]?,
53
51
  # ?messages: Array[Riffer::Messages::Base],
54
52
  # ?healed_tool_call_ids: Array[String],
@@ -57,10 +55,9 @@ class Riffer::Agent::Response
57
55
  # ) -> void
58
56
  def initialize(
59
57
  content,
58
+ outcome:,
60
59
  tripwire: nil,
61
60
  modifications: [],
62
- interrupted: false,
63
- interrupt_reason: nil,
64
61
  structured_output: nil,
65
62
  messages: [],
66
63
  healed_tool_call_ids: [],
@@ -68,10 +65,9 @@ class Riffer::Agent::Response
68
65
  steps: 0
69
66
  )
70
67
  @content = content
68
+ @outcome = outcome
71
69
  @tripwire = tripwire
72
70
  @modifications = modifications
73
- @interrupted = interrupted
74
- @interrupt_reason = interrupt_reason
75
71
  @structured_output = structured_output
76
72
  @messages = messages
77
73
  @healed_tool_call_ids = healed_tool_call_ids
@@ -79,14 +75,6 @@ class Riffer::Agent::Response
79
75
  @steps = steps
80
76
  end
81
77
 
82
- # Returns true if the response was blocked by a guardrail.
83
- #
84
- #--
85
- #: () -> bool
86
- def blocked?
87
- !tripwire.nil?
88
- end
89
-
90
78
  # Returns true if any guardrail modified data during processing.
91
79
  #
92
80
  #--
@@ -94,13 +82,4 @@ class Riffer::Agent::Response
94
82
  def modified?
95
83
  modifications.any?
96
84
  end
97
-
98
- # Returns true if the agent loop was interrupted by a callback
99
- # via <tt>throw :riffer_interrupt</tt>.
100
- #
101
- #--
102
- #: () -> bool
103
- def interrupted?
104
- @interrupted
105
- end
106
85
  end
@@ -155,6 +155,7 @@ module Riffer::Agent::Run
155
155
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
156
156
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
157
157
  accumulated_finish_reason = nil #: Symbol?
158
+ accumulated_finish_reason_raw = nil #: String?
158
159
 
159
160
  call_llm_stream(agent, tags).each do |event|
160
161
  stream_yielder << event
@@ -178,6 +179,7 @@ module Riffer::Agent::Run
178
179
  accumulated_token_usage = event.token_usage
179
180
  when Riffer::StreamEvents::FinishReasonDone
180
181
  accumulated_finish_reason = event.finish_reason
182
+ accumulated_finish_reason_raw = event.raw_finish_reason
181
183
  end
182
184
  end
183
185
 
@@ -186,6 +188,7 @@ module Riffer::Agent::Run
186
188
  tool_calls: accumulated_tool_calls,
187
189
  token_usage: accumulated_token_usage,
188
190
  finish_reason: accumulated_finish_reason,
191
+ finish_reason_raw: accumulated_finish_reason_raw,
189
192
  )
190
193
  end
191
194
 
@@ -207,6 +210,7 @@ module Riffer::Agent::Run
207
210
  build_response(
208
211
  agent,
209
212
  "",
213
+ outcome: Riffer::Agent::Outcome.new(reason: :guardrail_blocked, detail: tripwire.reason),
210
214
  tripwire: tripwire,
211
215
  modifications: all_modifications,
212
216
  token_usage: token_usage,
@@ -215,18 +219,41 @@ module Riffer::Agent::Run
215
219
  end
216
220
 
217
221
  #--
218
- #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
219
- def final_response(agent, all_modifications, **extra)
220
- response = agent.session.final_assistant_message
222
+ #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, **untyped) -> Riffer::Agent::Response
223
+ def final_response(agent, all_modifications, interrupted: false, interrupt_reason: nil, **extra)
224
+ message = agent.session.final_assistant_message
225
+ result = agent.structured_output && structured_output_result(agent, message)
221
226
  build_response(
222
227
  agent,
223
- response&.content || "",
228
+ message&.content || "",
229
+ outcome: final_outcome(message, result, interrupted: interrupted, interrupt_reason: interrupt_reason),
224
230
  modifications: all_modifications,
225
- structured_output: validate_structured_output(agent, response),
231
+ structured_output: result&.object,
226
232
  **extra,
227
233
  )
228
234
  end
229
235
 
236
+ # Checked in the order things happened. The loop being stopped (max_steps or
237
+ # an interrupt) beats the provider's finish reason, which beats riffer's own
238
+ # validation of the content. A truncated response that also fails the schema
239
+ # therefore reports :length, not :invalid_structured_output.
240
+ #--
241
+ #: (Riffer::Messages::Assistant?, Riffer::Agent::StructuredOutput::Result?, interrupted: bool, interrupt_reason: (String | Symbol)?) -> Riffer::Agent::Outcome
242
+ def final_outcome(message, result, interrupted:, interrupt_reason:)
243
+ finish_reason = message&.finish_reason
244
+ if interrupted && interrupt_reason == Riffer::Agent::INTERRUPT_MAX_STEPS
245
+ Riffer::Agent::Outcome.new(reason: :max_steps)
246
+ elsif interrupted
247
+ Riffer::Agent::Outcome.new(reason: :interrupted, detail: interrupt_reason&.to_s)
248
+ elsif finish_reason && !Riffer::Agent::Outcome::NORMAL_FINISH_REASONS.include?(finish_reason)
249
+ Riffer::Agent::Outcome.new(reason: finish_reason, detail: message&.finish_reason_raw)
250
+ elsif result&.failure?
251
+ Riffer::Agent::Outcome.new(reason: :invalid_structured_output, detail: result.error)
252
+ else
253
+ Riffer::Agent::Outcome.new(reason: :completed)
254
+ end
255
+ end
256
+
230
257
  #--
231
258
  #: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
232
259
  def call_llm(agent, tags = {})
@@ -327,11 +354,11 @@ module Riffer::Agent::Run
327
354
  end
328
355
 
329
356
  #--
330
- #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
331
- def validate_structured_output(agent, response)
332
- return unless response&.structured_output? && agent.structured_output
357
+ #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Riffer::Agent::StructuredOutput::Result?
358
+ def structured_output_result(agent, message)
359
+ return unless message
333
360
 
334
- agent.structured_output.parse_and_validate(response.content).object
361
+ agent.structured_output&.parse_and_validate(message.content)
335
362
  end
336
363
 
337
364
  #--
@@ -358,10 +385,9 @@ module Riffer::Agent::Run
358
385
  #: (
359
386
  # Riffer::Agent,
360
387
  # String,
388
+ # outcome: Riffer::Agent::Outcome,
361
389
  # ?tripwire: Riffer::Guardrails::Tripwire?,
362
390
  # ?modifications: Array[Riffer::Guardrails::Modification],
363
- # ?interrupted: bool,
364
- # ?interrupt_reason: (String | Symbol)?,
365
391
  # ?structured_output: Hash[Symbol, untyped]?,
366
392
  # ?healed_tool_call_ids: Array[String],
367
393
  # ?token_usage: Riffer::Providers::TokenUsage?,
@@ -370,10 +396,9 @@ module Riffer::Agent::Run
370
396
  def build_response(
371
397
  agent,
372
398
  content,
399
+ outcome:,
373
400
  tripwire: nil,
374
401
  modifications: [],
375
- interrupted: false,
376
- interrupt_reason: nil,
377
402
  structured_output: nil,
378
403
  healed_tool_call_ids: [],
379
404
  token_usage: nil,
@@ -382,10 +407,9 @@ module Riffer::Agent::Run
382
407
  messages = agent.session.messages
383
408
  Riffer::Agent::Response.new(
384
409
  content,
410
+ outcome: outcome,
385
411
  tripwire: tripwire,
386
412
  modifications: modifications,
387
- interrupted: interrupted,
388
- interrupt_reason: interrupt_reason,
389
413
  structured_output: structured_output,
390
414
  messages: messages.frozen? ? messages : messages.dup.freeze,
391
415
  healed_tool_call_ids: healed_tool_call_ids,
@@ -460,7 +484,12 @@ module Riffer::Agent::Run
460
484
  span.set_attribute("riffer.steps", response.steps)
461
485
  Riffer::Tracing.record_usage(span, response.token_usage)
462
486
 
463
- span.set_attribute("riffer.interrupt.reason", response.interrupt_reason.to_s) if response.interrupt_reason
487
+ outcome = response.outcome
488
+ span.set_attribute("riffer.outcome.reason", outcome.reason.to_s)
489
+ detail = outcome.detail
490
+ span.set_attribute("riffer.outcome.detail", detail) if detail
491
+ interrupt_reason = interrupt_reason_attribute(outcome)
492
+ span.set_attribute("riffer.interrupt.reason", interrupt_reason) if interrupt_reason
464
493
 
465
494
  tripwire = response.tripwire
466
495
  return unless tripwire
@@ -470,4 +499,13 @@ module Riffer::Agent::Run
470
499
  span.set_attribute("riffer.tripwire.reason", tripwire.reason)
471
500
  span.set_attribute("riffer.tripwire.phase", tripwire.phase.to_s)
472
501
  end
502
+
503
+ #--
504
+ #: (Riffer::Agent::Outcome) -> String?
505
+ def interrupt_reason_attribute(outcome)
506
+ case outcome.reason
507
+ when :max_steps then Riffer::Agent::INTERRUPT_MAX_STEPS.to_s
508
+ when :interrupted then outcome.detail
509
+ end
510
+ end
473
511
  end
@@ -203,6 +203,8 @@ class Riffer::Agent::Session
203
203
  tool_calls: attrs.fetch(:tool_calls, old.tool_calls),
204
204
  token_usage: attrs.fetch(:token_usage, old.token_usage),
205
205
  structured_output: attrs.fetch(:structured_output, old.structured_output),
206
+ finish_reason: attrs.fetch(:finish_reason, old.finish_reason),
207
+ finish_reason_raw: attrs.fetch(:finish_reason_raw, old.finish_reason_raw),
206
208
  )
207
209
  when Riffer::Messages::Tool
208
210
  Riffer::Messages::Tool.new(
@@ -19,11 +19,31 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
19
19
  # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
20
20
  attr_reader :finish_reason #: Symbol?
21
21
 
22
+ # The provider's raw finish-reason value behind +finish_reason+, when one
23
+ # exists on the wire.
24
+ attr_reader :finish_reason_raw #: String?
25
+
22
26
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
23
27
  # normalized vocabulary.
24
28
  #--
25
- #: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
26
- def initialize(content, id: nil, tool_calls: [], token_usage: nil, structured_output: nil, finish_reason: nil)
29
+ #: (
30
+ # String,
31
+ # ?id: String?,
32
+ # ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall],
33
+ # ?token_usage: Riffer::Providers::TokenUsage?,
34
+ # ?structured_output: Hash[Symbol, untyped]?,
35
+ # ?finish_reason: Symbol?,
36
+ # ?finish_reason_raw: String?
37
+ # ) -> void
38
+ def initialize(
39
+ content,
40
+ id: nil,
41
+ tool_calls: [],
42
+ token_usage: nil,
43
+ structured_output: nil,
44
+ finish_reason: nil,
45
+ finish_reason_raw: nil
46
+ )
27
47
  if finish_reason && !Riffer::Providers::FinishReason::VALUES.include?(finish_reason)
28
48
  values = Riffer::Providers::FinishReason::VALUES.inspect
29
49
  raise Riffer::ArgumentError, "finish_reason must be one of #{values}, got #{finish_reason.inspect}"
@@ -34,6 +54,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
34
54
  @token_usage = token_usage
35
55
  @structured_output = structured_output
36
56
  @finish_reason = finish_reason
57
+ @finish_reason_raw = finish_reason_raw
37
58
  end
38
59
 
39
60
  #--
@@ -71,6 +92,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
71
92
  hash[:token_usage] = token_usage.to_h if token_usage
72
93
  hash[:structured_output] = structured_output if structured_output?
73
94
  hash[:finish_reason] = finish_reason if finish_reason
95
+ hash[:finish_reason_raw] = finish_reason_raw if finish_reason_raw
74
96
  hash
75
97
  end
76
98
  end
@@ -14,38 +14,30 @@ class Riffer::Messages::Base
14
14
 
15
15
  raise Riffer::ArgumentError, "Message must be a Hash or Message object, got #{msg.class}" unless msg.is_a?(Hash)
16
16
 
17
- role = msg[:role]
18
- content = msg[:content]
17
+ raise Riffer::ArgumentError, "Message hash must include a 'role' key" if msg[:role].nil? || msg[:role].empty?
19
18
 
20
- raise Riffer::ArgumentError, "Message hash must include a 'role' key" if role.nil? || role.empty?
21
-
22
- id = msg[:id]
23
-
24
- case role.to_sym
19
+ case msg[:role].to_sym
25
20
  when :user
26
21
  files = (msg[:files] || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
27
- Riffer::Messages::User.new(content, id: id, files: files)
22
+ Riffer::Messages::User.new(msg[:content], id: msg[:id], files: files)
28
23
  when :assistant
29
24
  tool_calls = (msg[:tool_calls] || []).map do |tc|
30
25
  tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
31
26
  end
32
- structured_output = msg[:structured_output]
33
- finish_reason = msg[:finish_reason]&.to_sym
34
27
  Riffer::Messages::Assistant.new(
35
- content,
36
- id: id,
28
+ msg[:content],
29
+ id: msg[:id],
37
30
  tool_calls: tool_calls,
38
- structured_output: structured_output,
39
- finish_reason: finish_reason,
31
+ structured_output: msg[:structured_output],
32
+ finish_reason: msg[:finish_reason]&.to_sym,
33
+ finish_reason_raw: msg[:finish_reason_raw],
40
34
  )
41
35
  when :system
42
- Riffer::Messages::System.new(content, id: id)
36
+ Riffer::Messages::System.new(msg[:content], id: msg[:id])
43
37
  when :tool
44
- tool_call_id = msg[:tool_call_id]
45
- name = msg[:name]
46
- Riffer::Messages::Tool.new(content, id: id, tool_call_id: tool_call_id, name: name)
38
+ Riffer::Messages::Tool.new(msg[:content], id: msg[:id], tool_call_id: msg[:tool_call_id], name: msg[:name])
47
39
  else
48
- raise Riffer::ArgumentError, "Unknown message role: #{role}"
40
+ raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
49
41
  end
50
42
  end
51
43
 
@@ -96,7 +96,12 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
96
96
  # Use strict schema to make optional fields nullable. Without this,
97
97
  # Anthropic may return empty strings or whitespace instead of null
98
98
  # for optional fields that the model has no value for.
99
+ #
100
+ # Merged over any caller-supplied output_config (e.g. effort) so those
101
+ # keys survive; the structured-output format wins because the run loop
102
+ # validates the response against it.
99
103
  params[:output_config] = {
104
+ **(params[:output_config] || {}),
100
105
  format: {
101
106
  type: "json_schema",
102
107
  schema: structured_output.json_schema(strict: true),
@@ -71,6 +71,7 @@ class Riffer::Providers::Base
71
71
  token_usage: token_usage,
72
72
  structured_output: structured_output,
73
73
  finish_reason: finish_reason&.reason,
74
+ finish_reason_raw: finish_reason&.raw,
74
75
  )
75
76
  end
76
77
  end
@@ -5,6 +5,8 @@
5
5
  # +max_concurrency+ caps simultaneous fibers via an <tt>Async::Semaphore</tt>.
6
6
  # If multiple fibers raise, only the first exception is re-raised after all
7
7
  # finish.
8
+ # Joins the current reactor task when one is already running, and otherwise
9
+ # starts its own.
8
10
  class Riffer::Runner::Fibers < Riffer::Runner
9
11
  # @rbs @max_concurrency: Integer?
10
12
 
@@ -25,15 +27,15 @@ class Riffer::Runner::Fibers < Riffer::Runner
25
27
  results = Array.new(items.size)
26
28
  errors = Array.new(items.size)
27
29
 
28
- Async do
29
- barrier = Async::Barrier.new
30
- max = @max_concurrency
31
- parent = if max
32
- Async::Semaphore.new(max, parent: barrier)
33
- else
34
- barrier
35
- end
30
+ barrier = Async::Barrier.new
31
+ max = @max_concurrency
32
+ parent = if max
33
+ Async::Semaphore.new(max, parent: barrier)
34
+ else
35
+ barrier
36
+ end
36
37
 
38
+ Sync do
37
39
  items.each_with_index do |item, index|
38
40
  parent.async do
39
41
  results[index] = yield(item)
@@ -43,6 +45,8 @@ class Riffer::Runner::Fibers < Riffer::Runner
43
45
  end
44
46
 
45
47
  barrier.wait
48
+ ensure
49
+ barrier.stop
46
50
  end
47
51
 
48
52
  first_error = errors.compact.first
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.45.0" #: String
5
+ VERSION = "0.46.0" #: String
6
6
  end
@@ -10,6 +10,8 @@ module Async
10
10
  def async: () { () -> void } -> untyped
11
11
 
12
12
  def wait: () -> void
13
+
14
+ def stop: () -> void
13
15
  end
14
16
 
15
17
  class Semaphore
@@ -21,4 +23,6 @@ end
21
23
 
22
24
  module Kernel
23
25
  def Async: () { () -> void } -> untyped
26
+
27
+ def Sync: () { () -> void } -> untyped
24
28
  end
@@ -0,0 +1,41 @@
1
+ # Generated from lib/riffer/agent/outcome.rb with RBS::Inline
2
+
3
+ # How a run ended — the single place to read whether the agent completed
4
+ # normally and, if not, why. +detail+ carries the specifics when there are any:
5
+ # the tripwire reason, the interrupt reason, the provider's raw finish value,
6
+ # or the structured output parse/validation error.
7
+ #
8
+ # response = agent.generate("Analyze this")
9
+ # case response.outcome.reason
10
+ # when :completed then puts response.structured_output
11
+ # when :invalid_structured_output then warn response.outcome.detail
12
+ # end
13
+ class Riffer::Agent::Outcome
14
+ # Finish reasons that end a turn normally; every other finish reason means the
15
+ # provider cut the turn short and surfaces as the run's outcome verbatim.
16
+ NORMAL_FINISH_REASONS: Array[Symbol]
17
+
18
+ # Derived from the provider vocabulary so a new finish reason becomes an
19
+ # outcome without a second list to update.
20
+ PROVIDER_STOP_REASONS: Array[Symbol]
21
+
22
+ # The vocabulary every run ends in.
23
+ VALUES: Array[Symbol]
24
+
25
+ # Why the run ended.
26
+ attr_reader reason: Symbol
27
+
28
+ # Human-readable specifics for +reason+, when there are any.
29
+ attr_reader detail: String?
30
+
31
+ # Raises Riffer::ArgumentError when +reason+ is outside VALUES.
32
+ # --
33
+ # : (reason: Symbol, ?detail: String?) -> void
34
+ def initialize: (reason: Symbol, ?detail: String?) -> void
35
+
36
+ # Returns true when the run completed normally.
37
+ #
38
+ # --
39
+ # : () -> bool
40
+ def success?: () -> bool
41
+ end
@@ -1,29 +1,28 @@
1
1
  # Generated from lib/riffer/agent/response.rb with RBS::Inline
2
2
 
3
- # Wraps an agent generation response. When a guardrail blocks execution,
4
- # +content+ is empty and +tripwire+ carries the block details.
3
+ # Wraps an agent generation response. +outcome+ says how the run ended; when a
4
+ # guardrail blocks execution, +content+ is empty and +tripwire+ carries the
5
+ # block details.
5
6
  #
6
7
  # response = agent.generate("Hello")
7
- # if response.blocked?
8
- # puts "Blocked: #{response.tripwire.reason}"
9
- # else
8
+ # if response.outcome.success?
10
9
  # puts response.content
10
+ # else
11
+ # puts "#{response.outcome.reason}: #{response.outcome.detail}"
11
12
  # end
12
13
  class Riffer::Agent::Response
13
- @interrupted: bool
14
-
15
14
  # The response content.
16
15
  attr_reader content: String
17
16
 
17
+ # How the run ended.
18
+ attr_reader outcome: Riffer::Agent::Outcome
19
+
18
20
  # The tripwire if execution was blocked.
19
21
  attr_reader tripwire: Riffer::Guardrails::Tripwire?
20
22
 
21
23
  # The modifications made by guardrails during processing.
22
24
  attr_reader modifications: Array[Riffer::Guardrails::Modification]
23
25
 
24
- # The reason provided with the interrupt, if any.
25
- attr_reader interrupt_reason: (String | Symbol)?
26
-
27
26
  # The parsed structured output, if structured output was configured.
28
27
  attr_reader structured_output: Hash[Symbol, untyped]?
29
28
 
@@ -44,34 +43,20 @@ class Riffer::Agent::Response
44
43
  # --
45
44
  # : (
46
45
  # String,
46
+ # outcome: Riffer::Agent::Outcome,
47
47
  # ?tripwire: Riffer::Guardrails::Tripwire?,
48
48
  # ?modifications: Array[Riffer::Guardrails::Modification],
49
- # ?interrupted: bool,
50
- # ?interrupt_reason: (String | Symbol)?,
51
49
  # ?structured_output: Hash[Symbol, untyped]?,
52
50
  # ?messages: Array[Riffer::Messages::Base],
53
51
  # ?healed_tool_call_ids: Array[String],
54
52
  # ?token_usage: Riffer::Providers::TokenUsage?,
55
53
  # ?steps: Integer
56
54
  # ) -> void
57
- def initialize: (String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> void
58
-
59
- # Returns true if the response was blocked by a guardrail.
60
- #
61
- # --
62
- # : () -> bool
63
- def blocked?: () -> bool
55
+ def initialize: (String, outcome: Riffer::Agent::Outcome, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?structured_output: Hash[Symbol, untyped]?, ?messages: Array[Riffer::Messages::Base], ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> void
64
56
 
65
57
  # Returns true if any guardrail modified data during processing.
66
58
  #
67
59
  # --
68
60
  # : () -> bool
69
61
  def modified?: () -> bool
70
-
71
- # Returns true if the agent loop was interrupted by a callback
72
- # via <tt>throw :riffer_interrupt</tt>.
73
- #
74
- # --
75
- # : () -> bool
76
- def interrupted?: () -> bool
77
62
  end
@@ -45,8 +45,16 @@ module Riffer::Agent::Run
45
45
  def tripwire_response: (Riffer::Agent, Enumerator::Yielder?, Riffer::Guardrails::Tripwire, Array[Riffer::Guardrails::Modification], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
46
46
 
47
47
  # --
48
- # : (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
49
- def final_response: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
48
+ # : (Riffer::Agent, Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, **untyped) -> Riffer::Agent::Response
49
+ def final_response: (Riffer::Agent, Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, **untyped) -> Riffer::Agent::Response
50
+
51
+ # Checked in the order things happened. The loop being stopped (max_steps or
52
+ # an interrupt) beats the provider's finish reason, which beats riffer's own
53
+ # validation of the content. A truncated response that also fails the schema
54
+ # therefore reports :length, not :invalid_structured_output.
55
+ # --
56
+ # : (Riffer::Messages::Assistant?, Riffer::Agent::StructuredOutput::Result?, interrupted: bool, interrupt_reason: (String | Symbol)?) -> Riffer::Agent::Outcome
57
+ def final_outcome: (Riffer::Messages::Assistant?, Riffer::Agent::StructuredOutput::Result?, interrupted: bool, interrupt_reason: (String | Symbol)?) -> Riffer::Agent::Outcome
50
58
 
51
59
  # --
52
60
  # : (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
@@ -77,8 +85,8 @@ module Riffer::Agent::Run
77
85
  def run_after_guardrails: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
78
86
 
79
87
  # --
80
- # : (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
81
- def validate_structured_output: (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
88
+ # : (Riffer::Agent, Riffer::Messages::Assistant?) -> Riffer::Agent::StructuredOutput::Result?
89
+ def structured_output_result: (Riffer::Agent, Riffer::Messages::Assistant?) -> Riffer::Agent::StructuredOutput::Result?
82
90
 
83
91
  # --
84
92
  # : (Riffer::Agent) -> Array[singleton(Riffer::Tool)]
@@ -96,16 +104,15 @@ module Riffer::Agent::Run
96
104
  # : (
97
105
  # Riffer::Agent,
98
106
  # String,
107
+ # outcome: Riffer::Agent::Outcome,
99
108
  # ?tripwire: Riffer::Guardrails::Tripwire?,
100
109
  # ?modifications: Array[Riffer::Guardrails::Modification],
101
- # ?interrupted: bool,
102
- # ?interrupt_reason: (String | Symbol)?,
103
110
  # ?structured_output: Hash[Symbol, untyped]?,
104
111
  # ?healed_tool_call_ids: Array[String],
105
112
  # ?token_usage: Riffer::Providers::TokenUsage?,
106
113
  # ?steps: Integer
107
114
  # ) -> Riffer::Agent::Response
108
- def build_response: (Riffer::Agent, String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
115
+ def build_response: (Riffer::Agent, String, outcome: Riffer::Agent::Outcome, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
109
116
 
110
117
  # Raises when +files+ are supplied without a +prompt+ — the provider needs
111
118
  # text to anchor the attachments.
@@ -136,4 +143,8 @@ module Riffer::Agent::Run
136
143
  # --
137
144
  # : (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span, Riffer::Agent::Response) -> void
138
145
  def record_run_outcome: (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span, Riffer::Agent::Response) -> void
146
+
147
+ # --
148
+ # : (Riffer::Agent::Outcome) -> String?
149
+ def interrupt_reason_attribute: (Riffer::Agent::Outcome) -> String?
139
150
  end
@@ -27,11 +27,23 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
27
27
  # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
28
28
  attr_reader finish_reason: Symbol?
29
29
 
30
+ # The provider's raw finish-reason value behind +finish_reason+, when one
31
+ # exists on the wire.
32
+ attr_reader finish_reason_raw: String?
33
+
30
34
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
31
35
  # normalized vocabulary.
32
36
  # --
33
- # : (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
34
- def initialize: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
37
+ # : (
38
+ # String,
39
+ # ?id: String?,
40
+ # ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall],
41
+ # ?token_usage: Riffer::Providers::TokenUsage?,
42
+ # ?structured_output: Hash[Symbol, untyped]?,
43
+ # ?finish_reason: Symbol?,
44
+ # ?finish_reason_raw: String?
45
+ # ) -> void
46
+ def initialize: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?, ?finish_reason_raw: String?) -> void
35
47
 
36
48
  # --
37
49
  # : () -> Symbol
@@ -4,6 +4,8 @@
4
4
  # +max_concurrency+ caps simultaneous fibers via an <tt>Async::Semaphore</tt>.
5
5
  # If multiple fibers raise, only the first exception is re-raised after all
6
6
  # finish.
7
+ # Joins the current reactor task when one is already running, and otherwise
8
+ # starts its own.
7
9
  class Riffer::Runner::Fibers < Riffer::Runner
8
10
  @max_concurrency: Integer?
9
11
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: riffer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.45.0
4
+ version: 0.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall
@@ -110,6 +110,7 @@ files:
110
110
  - lib/riffer/agent.rb
111
111
  - lib/riffer/agent/config.rb
112
112
  - lib/riffer/agent/context.rb
113
+ - lib/riffer/agent/outcome.rb
113
114
  - lib/riffer/agent/response.rb
114
115
  - lib/riffer/agent/run.rb
115
116
  - lib/riffer/agent/serializer.rb
@@ -239,6 +240,7 @@ files:
239
240
  - sig/generated/riffer/agent.rbs
240
241
  - sig/generated/riffer/agent/config.rbs
241
242
  - sig/generated/riffer/agent/context.rbs
243
+ - sig/generated/riffer/agent/outcome.rbs
242
244
  - sig/generated/riffer/agent/response.rbs
243
245
  - sig/generated/riffer/agent/run.rbs
244
246
  - sig/generated/riffer/agent/serializer.rbs