riffer 0.42.0 → 0.43.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: eabed1a1a6844787d92f31be0a72e210117005c19b4dd5e021a905121f83e8e1
4
- data.tar.gz: 4cc6b6bde7e4b474d7ca6fb2672a3e115d4584b096f1de5d967a2b1a751f49ba
3
+ metadata.gz: bed1103d2399d8532635b4041d4a24b8264f160b58a3110436ed67a14380fd56
4
+ data.tar.gz: acdaa65506e0c4887d388663be686bc02ccf668c26db281148efcec9f5772d6b
5
5
  SHA512:
6
- metadata.gz: 220febe23864053fa767e3eecc6850dc0c25ebdcb363a86d5daa58cd317c5500d08f2b50f499f133a77d7b4cf5182485c6faf0ddf36550099b22b7dd203af0c6
7
- data.tar.gz: d231a4b0dc8c0468f4cba1abf9e3ace390852efc4b6adf5c23229c6729d4ad71fb0afdd5ae5b50540a188b79ec519ef58f95972357fb9854398570d0de058781
6
+ metadata.gz: 0733aa1731a7206195862e6bfd59936b80cc3219fa913a6afce577a8de9d8f4510ca59316a6bb27c6a778706b6d231ce61cd74ebd9d63f32d2a695453b2bd5ae
7
+ data.tar.gz: da09bd24389e52f79c7cbe3dd977c5ada7bc9f0a923f2d09d2331245b4ca1fd079f9f688da0ab8b36eac6b1a260bd627831dc0c344ae93422f5a006c929a11e3
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.42.0"
2
+ ".": "0.43.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ 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.43.0](https://github.com/janeapp/riffer/compare/riffer/v0.42.0...riffer/v0.43.0) (2026-08-27)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * call_with_validation no longer raises Riffer::ValidationError / Riffer::TimeoutError / Riffer::Error — it always returns a Riffer::Tools::Response. Riffer::TimeoutError has been removed; delete any `rescue Riffer::TimeoutError` and check `response.error_type` instead. Custom runtimes overriding dispatch_tool_call no longer inherit rescues from the base class.
14
+
15
+ ### Features
16
+
17
+ * tools are the error boundary — call_with_validation never raises ([#412](https://github.com/janeapp/riffer/issues/412)) ([b6db9b6](https://github.com/janeapp/riffer/commit/b6db9b6ab2885d170cbba52c7a571c63b83d892e))
18
+
8
19
  ## [0.42.0](https://github.com/janeapp/riffer/compare/riffer/v0.41.0...riffer/v0.42.0) (2026-08-25)
9
20
 
10
21
 
data/docs/AGENT_LOOP.md CHANGED
@@ -15,13 +15,15 @@ When an agent receives a response with tool calls:
15
15
 
16
16
  ## Error Handling
17
17
 
18
- Tool execution errors are captured and sent back to the LLM:
18
+ Tool execution errors never abort the loop — each is captured and sent back to the LLM as a tool result:
19
19
 
20
20
  - `unknown_tool` - Tool not found in registered tools
21
- - `validation_error` - Arguments failed validation
22
- - `execution_error` - Tool raised an exception
21
+ - `validation_error` - Arguments failed validation or were malformed JSON
22
+ - `timeout_error` - Tool exceeded its configured timeout
23
+ - `execution_error` - Tool returned an error or raised `Riffer::ToolExecutionError`
24
+ - `unhandled_error` - Tool raised an unanticipated exception
23
25
 
24
- The LLM can use this information to retry or respond appropriately.
26
+ The LLM can use this information to retry or respond appropriately. See [Error Handling](TOOL_ADVANCED.md#error-handling) for details.
25
27
 
26
28
  ## Ways the Agent Loop Can Stop
27
29
 
data/docs/TOOLS.md CHANGED
@@ -269,7 +269,7 @@ error("Service unavailable", type: :service_error)
269
269
  error("Rate limit exceeded", type: :rate_limit)
270
270
  ```
271
271
 
272
- If no type is specified, it defaults to `:execution_error`.
272
+ If no type is specified, it defaults to `:execution_error`. Riffer reserves `:unhandled_error` for unrescued exceptions — don't set it yourself.
273
273
 
274
274
  ### Using Riffer::Tools::Response Directly
275
275
 
@@ -297,4 +297,7 @@ error_response.success? # => false
297
297
  error_response.error? # => true
298
298
  error_response.error_message # => "failed"
299
299
  error_response.error_type # => :not_found
300
+ error_response.exception # => nil
300
301
  ```
302
+
303
+ `exception` holds the rescued exception on the `:unhandled_error` responses Riffer builds. It never appears in serialized output, so it is not visible to the LLM.
@@ -16,7 +16,7 @@ class SlowExternalApiTool < Riffer::Tool
16
16
  end
17
17
  ```
18
18
 
19
- When a tool times out, the error is reported to the LLM with error type `:timeout_error`, allowing it to respond appropriately (e.g., suggest retrying or using a different approach).
19
+ When a tool times out, the LLM receives an error response with type `:timeout_error` and can respond appropriately (e.g., suggest retrying or using a different approach). The timeout raises `Riffer::TimeoutError` inside `call`, so a tool can rescue it to release resources before it propagates.
20
20
 
21
21
  ## Validation
22
22
 
@@ -26,7 +26,7 @@ Arguments are automatically validated before `call` is invoked:
26
26
  - Types must match the schema
27
27
  - Enum values must be in the allowed list
28
28
 
29
- Validation errors are captured and sent back to the LLM as tool results with error type `:validation_error`.
29
+ Validation errors are captured and sent back to the LLM as tool results with error type `:validation_error`, as is malformed or non-object JSON in the provider's tool-call arguments.
30
30
 
31
31
  ## JSON Schema Generation
32
32
 
@@ -95,7 +95,20 @@ rescue => e
95
95
  end
96
96
  ```
97
97
 
98
- Unhandled `RuntimeError` exceptions are caught by Riffer and converted to error responses with type `:execution_error`. For expected execution errors, raise `Riffer::ToolExecutionError` these are also caught and returned to the LLM. Programming bugs (`NoMethodError`, `NameError`, `TypeError`, etc.) propagate to the caller. It's recommended to handle expected errors explicitly for better error messages.
98
+ A tool never raises into the agent loopevery `StandardError` raised during a tool call becomes an error response:
99
+
100
+ | Failure | Error type | Response content |
101
+ | ---------------------------- | ------------------- | ---------------------------------------------------- |
102
+ | Invalid arguments | `:validation_error` | the validation message |
103
+ | Timeout | `:timeout_error` | `Tool execution timed out after N seconds` |
104
+ | `Riffer::ToolExecutionError` | `:execution_error` | the exception message |
105
+ | Any other `StandardError` | `:unhandled_error` | `Error executing tool: <ExceptionClass>: <message>` |
106
+
107
+ An `:unhandled_error` response also carries the rescued exception on `response.exception` — never serialized, so it stays out of the message history — and its `execute_tool` span records the exception with an `ERROR` status (see [Tracing](TRACING.md)).
108
+
109
+ `NotImplementedError` is not rescued: an unimplemented `#call` raises out of the run.
110
+
111
+ For expected failures, return `error(...)` or raise `Riffer::ToolExecutionError` — both give the LLM a clean message rather than an `:unhandled_error`.
99
112
 
100
113
  The LLM receives the error message and can decide how to respond (retry, apologize, ask for different input, etc.).
101
114
 
@@ -222,14 +235,14 @@ class HttpToolRuntime < Riffer::Tools::Runtime
222
235
  arguments: tool_call.arguments
223
236
  })
224
237
  Riffer::Tools::Response.text(response.body)
225
- rescue Riffer::ToolExecutionError => e
226
- Riffer::Tools::Response.error(e.message, type: :execution_error)
227
- rescue RuntimeError => e
228
- Riffer::Tools::Response.error("Error executing tool: #{e.message}", type: :execution_error)
238
+ rescue HttpClient::Error => e
239
+ Riffer::Tools::Response.error("Tool service unavailable: #{e.message}", type: :execution_error)
229
240
  end
230
241
  end
231
242
  ```
232
243
 
244
+ Anything that escapes `dispatch_tool_call` propagates out of the run — rescue whatever your transport can raise and return an error response. The base class handles only an unknown tool name and malformed argument JSON.
245
+
233
246
  ### Around-Call Hook
234
247
 
235
248
  Each tool call is wrapped by the `around_tool_call` method, which yields by default. Override it in a subclass to add instrumentation, logging, or other cross-cutting concerns:
data/docs/TRACING.md CHANGED
@@ -168,12 +168,13 @@ Usage on this span is the run total, aggregated across every step. See [Token us
168
168
  | `gen_ai.tool.call.arguments` | string | When `capture_messages` is on (see [capture](#message-content-capture)) |
169
169
  | `gen_ai.tool.call.result` | string | When `capture_messages` is on |
170
170
 
171
- A tool failure comes in two shapes, distinguished by span status:
171
+ A tool failure comes in three shapes, distinguished by span status:
172
172
 
173
- - **Handled error** — the tool returned an error response. `error.type` carries the category and the **span status stays unset** (the run continues). The framework's categories are `unknown_tool`, `validation_error`, `timeout_error`, and `execution_error`; a custom tool may set its own via `Riffer::Tools::Response.error(type:)`.
174
- - **Unhandled exception** — the dispatch raised. `error.type` is the exception class name and the **span status is `ERROR`**, with the exception recorded.
173
+ - **Handled error** — the tool call produced a deliberate error response, from the tool itself or from the runtime (an unknown tool, malformed arguments). `error.type` carries the category and the **span status stays unset** (the run continues). The framework's categories are `unknown_tool`, `validation_error`, `timeout_error`, and `execution_error`; a custom tool may set its own via `Riffer::Tools::Response.error(type:)`.
174
+ - **Unhandled error** — the tool raised an unanticipated `StandardError`. `error.type` is `unhandled_error`, the **span status is `ERROR`**, and the exception is recorded on the span. The run continues — the LLM receives the error response.
175
+ - **Host code raising** around the tool call (an `around_tool_call` hook, a tracing callback) propagates out of the run. `error.type` is the exception class name and the span status is `ERROR`.
175
176
 
176
- This status convention is the same on `chat` and `invoke_agent`: an unhandled exception sets `error.type` to the class name and marks the span `ERROR`; everything else leaves the status unset.
177
+ This status convention is the same on `chat` and `invoke_agent`: an unhandled exception marks the span `ERROR` with the exception recorded; a handled outcome leaves the status unset.
177
178
 
178
179
  ## `execute_guardrail {name}` — the guardrail span
179
180
 
@@ -12,7 +12,8 @@ class Riffer::Messages::Tool < Riffer::Messages::Base
12
12
  # The error message if the tool execution failed.
13
13
  attr_reader :error #: String?
14
14
 
15
- # The type of error (:unknown_tool, :validation_error, :execution_error, :timeout_error).
15
+ # The type of error (:unknown_tool, :validation_error, :execution_error,
16
+ # :timeout_error, :unhandled_error).
16
17
  attr_reader :error_type #: Symbol?
17
18
 
18
19
  #--
data/lib/riffer/tool.rb CHANGED
@@ -56,19 +56,23 @@ class Riffer::Tool
56
56
  Riffer::Tools::Response.error(message, type: type)
57
57
  end
58
58
 
59
- # Executes the tool with validation and timeout (used by Agent).
60
- #
61
- # Raises Riffer::ValidationError if validation fails.
62
- # Raises Riffer::TimeoutError if execution exceeds the configured timeout.
63
- # Raises Riffer::Error if the tool does not return a Response object.
59
+ # Executes the tool with validation and timeout, folding every +StandardError+
60
+ # into an error Response. Anything outside +StandardError+ — an unimplemented
61
+ # +#call+ above all still propagates, because a broken tool is a broken
62
+ # deploy rather than a bad request.
64
63
  #
65
64
  #--
66
65
  #: (context: Riffer::Agent::Context?, **untyped) -> Riffer::Tools::Response
67
66
  def call_with_validation(context:, **kwargs)
68
67
  params_builder = self.class.params
69
- validated_args = params_builder ? params_builder.validate(kwargs) : kwargs
70
68
 
71
- result = Timeout.timeout(self.class.timeout) do
69
+ begin
70
+ validated_args = params_builder ? params_builder.validate(kwargs) : kwargs
71
+ rescue Riffer::ValidationError => e
72
+ return Riffer::Tools::Response.error(e.message, type: :validation_error)
73
+ end
74
+
75
+ result = Timeout.timeout(self.class.timeout, Riffer::TimeoutError) do
72
76
  call(context: context, **validated_args) #: untyped
73
77
  end
74
78
 
@@ -77,7 +81,18 @@ class Riffer::Tool
77
81
  end
78
82
 
79
83
  result
80
- rescue Timeout::Error
81
- raise Riffer::TimeoutError, "Tool execution timed out after #{self.class.timeout} seconds"
84
+ rescue Riffer::TimeoutError
85
+ Riffer::Tools::Response.error(
86
+ "Tool execution timed out after #{self.class.timeout} seconds",
87
+ type: :timeout_error,
88
+ )
89
+ rescue Riffer::ToolExecutionError => e
90
+ Riffer::Tools::Response.error(e.message, type: :execution_error)
91
+ rescue StandardError => e
92
+ Riffer::Tools::Response.error(
93
+ "Error executing tool: #{e.class}: #{e.message}",
94
+ type: :unhandled_error,
95
+ exception: e,
96
+ )
82
97
  end
83
98
  end
@@ -28,6 +28,10 @@ class Riffer::Tools::Response
28
28
  # The error type, or +nil+ on success.
29
29
  attr_reader :error_type #: Symbol?
30
30
 
31
+ # The exception an unhandled failure was folded from, or +nil+. Kept out of
32
+ # every serialized form so it never reaches an LLM or a message payload.
33
+ attr_reader :exception #: Exception?
34
+
31
35
  # Creates a success response.
32
36
  #
33
37
  # Raises Riffer::ArgumentError if format is invalid.
@@ -62,9 +66,9 @@ class Riffer::Tools::Response
62
66
  # Creates an error response.
63
67
  #
64
68
  #--
65
- #: (String, ?type: Symbol) -> Riffer::Tools::Response
66
- def self.error(message, type: :execution_error)
67
- new(content: message, success: false, error_message: message, error_type: type)
69
+ #: (String, ?type: Symbol, ?exception: Exception?) -> Riffer::Tools::Response
70
+ def self.error(message, type: :execution_error, exception: nil)
71
+ new(content: message, success: false, error_message: message, error_type: type, exception: exception)
68
72
  end
69
73
 
70
74
  # Returns true if the tool execution succeeded.
@@ -88,11 +92,12 @@ class Riffer::Tools::Response
88
92
  private
89
93
 
90
94
  #--
91
- #: (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?) -> void
92
- def initialize(content:, success:, error_message: nil, error_type: nil)
95
+ #: (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?, ?exception: Exception?) -> void
96
+ def initialize(content:, success:, error_message: nil, error_type: nil, exception: nil)
93
97
  @content = content
94
98
  @success = success
95
99
  @error_message = error_message
96
100
  @error_type = error_type
101
+ @exception = exception
97
102
  end
98
103
  end
@@ -88,19 +88,20 @@ class Riffer::Tools::Runtime
88
88
  tool_instance = tool_class.new
89
89
  arguments = parse_arguments(tool_call.arguments)
90
90
 
91
+ unless arguments.is_a?(Hash)
92
+ return Riffer::Tools::Response.error(
93
+ "Invalid JSON in tool arguments: expected an object, got #{arguments.class}",
94
+ type: :validation_error,
95
+ )
96
+ end
97
+
91
98
  tool_instance.call_with_validation(context: context, **arguments)
92
- rescue Riffer::TimeoutError => e
93
- Riffer::Tools::Response.error(e.message, type: :timeout_error)
94
- rescue Riffer::ValidationError => e
95
- Riffer::Tools::Response.error(e.message, type: :validation_error)
96
- rescue Riffer::ToolExecutionError => e
97
- Riffer::Tools::Response.error(e.message, type: :execution_error)
98
- rescue RuntimeError => e
99
- Riffer::Tools::Response.error("Error executing tool: #{e.message}", type: :execution_error)
99
+ rescue JSON::ParserError => e
100
+ Riffer::Tools::Response.error("Invalid JSON in tool arguments: #{e.message}", type: :validation_error)
100
101
  end
101
102
 
102
103
  #--
103
- #: (String?) -> Hash[Symbol, untyped]
104
+ #: (String?) -> untyped
104
105
  def parse_arguments(arguments)
105
106
  return {} if arguments.nil? || arguments.empty?
106
107
 
@@ -144,13 +145,21 @@ class Riffer::Tools::Runtime
144
145
  tags.transform_keys { |key| "riffer.tag.#{key}" }
145
146
  end
146
147
 
147
- # A returned error Response is a handled outcome, so its status stays unset
148
- # an error span status is reserved for a raised exception.
148
+ # A deliberate error Response is a handled outcome, so its status stays unset.
149
+ # An error status is reserved for a Response carrying the exception it was
150
+ # folded from — the tool failed for a reason nobody anticipated.
149
151
  #--
150
152
  #: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tools::Response) -> void
151
153
  def record_tool_outcome(span, result)
152
154
  error_type = result.error_type
153
155
  span.set_attribute("error.type", error_type.to_s) if error_type
156
+
157
+ exception = result.exception
158
+ if exception
159
+ span.record_exception(exception)
160
+ span.error!(exception.message)
161
+ end
162
+
154
163
  capture_tool_result(span, result)
155
164
  end
156
165
 
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.42.0" #: String
5
+ VERSION = "0.43.0" #: String
6
6
  end
data/lib/riffer.rb CHANGED
@@ -26,7 +26,9 @@ module Riffer
26
26
  # Raised when tool parameter validation fails.
27
27
  class ValidationError < Error; end
28
28
 
29
- # Raised when tool execution times out.
29
+ # Raised inside a tool's +call+ when execution exceeds the configured
30
+ # timeout. Rescue it in the tool to clean up; otherwise it becomes a
31
+ # +:timeout_error+ response.
30
32
  class TimeoutError < Error; end
31
33
 
32
34
  # Raised when a tool encounters an expected execution error.
@@ -11,7 +11,8 @@ class Riffer::Messages::Tool < Riffer::Messages::Base
11
11
  # The error message if the tool execution failed.
12
12
  attr_reader error: String?
13
13
 
14
- # The type of error (:unknown_tool, :validation_error, :execution_error, :timeout_error).
14
+ # The type of error (:unknown_tool, :validation_error, :execution_error,
15
+ # :timeout_error, :unhandled_error).
15
16
  attr_reader error_type: Symbol?
16
17
 
17
18
  # --
@@ -43,11 +43,10 @@ class Riffer::Tool
43
43
  # : (String, ?type: Symbol) -> Riffer::Tools::Response
44
44
  def error: (String, ?type: Symbol) -> Riffer::Tools::Response
45
45
 
46
- # Executes the tool with validation and timeout (used by Agent).
47
- #
48
- # Raises Riffer::ValidationError if validation fails.
49
- # Raises Riffer::TimeoutError if execution exceeds the configured timeout.
50
- # Raises Riffer::Error if the tool does not return a Response object.
46
+ # Executes the tool with validation and timeout, folding every +StandardError+
47
+ # into an error Response. Anything outside +StandardError+ — an unimplemented
48
+ # +#call+ above all still propagates, because a broken tool is a broken
49
+ # deploy rather than a bad request.
51
50
  #
52
51
  # --
53
52
  # : (context: Riffer::Agent::Context?, **untyped) -> Riffer::Tools::Response
@@ -24,6 +24,10 @@ class Riffer::Tools::Response
24
24
  # The error type, or +nil+ on success.
25
25
  attr_reader error_type: Symbol?
26
26
 
27
+ # The exception an unhandled failure was folded from, or +nil+. Kept out of
28
+ # every serialized form so it never reaches an LLM or a message payload.
29
+ attr_reader exception: Exception?
30
+
27
31
  # Creates a success response.
28
32
  #
29
33
  # Raises Riffer::ArgumentError if format is invalid.
@@ -47,8 +51,8 @@ class Riffer::Tools::Response
47
51
  # Creates an error response.
48
52
  #
49
53
  # --
50
- # : (String, ?type: Symbol) -> Riffer::Tools::Response
51
- def self.error: (String, ?type: Symbol) -> Riffer::Tools::Response
54
+ # : (String, ?type: Symbol, ?exception: Exception?) -> Riffer::Tools::Response
55
+ def self.error: (String, ?type: Symbol, ?exception: Exception?) -> Riffer::Tools::Response
52
56
 
53
57
  # Returns true if the tool execution succeeded.
54
58
  # --
@@ -69,6 +73,6 @@ class Riffer::Tools::Response
69
73
  private
70
74
 
71
75
  # --
72
- # : (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?) -> void
73
- def initialize: (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?) -> void
76
+ # : (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?, ?exception: Exception?) -> void
77
+ def initialize: (content: String, success: bool, ?error_message: String?, ?error_type: Symbol?, ?exception: Exception?) -> void
74
78
  end
@@ -44,8 +44,8 @@ class Riffer::Tools::Runtime
44
44
  def dispatch_tool_call: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
45
45
 
46
46
  # --
47
- # : (String?) -> Hash[Symbol, untyped]
48
- def parse_arguments: (String?) -> Hash[Symbol, untyped]
47
+ # : (String?) -> untyped
48
+ def parse_arguments: (String?) -> untyped
49
49
 
50
50
  # Emitted outside +around_tool_call+ so host enrichment spans nest beneath it.
51
51
  # --
@@ -62,8 +62,9 @@ class Riffer::Tools::Runtime
62
62
  # : (Hash[String, String]) -> Hash[String, String]
63
63
  def tag_attributes: (Hash[String, String]) -> Hash[String, String]
64
64
 
65
- # A returned error Response is a handled outcome, so its status stays unset
66
- # an error span status is reserved for a raised exception.
65
+ # A deliberate error Response is a handled outcome, so its status stays unset.
66
+ # An error status is reserved for a Response carrying the exception it was
67
+ # folded from — the tool failed for a reason nobody anticipated.
67
68
  # --
68
69
  # : ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tools::Response) -> void
69
70
  def record_tool_outcome: (Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span, Riffer::Tools::Response) -> void
@@ -15,7 +15,9 @@ module Riffer
15
15
  class ValidationError < Error
16
16
  end
17
17
 
18
- # Raised when tool execution times out.
18
+ # Raised inside a tool's +call+ when execution exceeds the configured
19
+ # timeout. Rescue it in the tool to clean up; otherwise it becomes a
20
+ # +:timeout_error+ response.
19
21
  class TimeoutError < Error
20
22
  end
21
23
 
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.42.0
4
+ version: 0.43.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jake Bottrall