riffer 0.41.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/{AGENTS.md → .claude/CLAUDE.md} +0 -8
  3. data/.claude/rules/comments.md +13 -0
  4. data/{.agents → .claude/rules}/rbs-inline.md +28 -104
  5. data/.release-please-manifest.json +1 -1
  6. data/CHANGELOG.md +22 -0
  7. data/docs/AGENTS.md +24 -3
  8. data/docs/AGENT_LIFECYCLE.md +2 -2
  9. data/docs/AGENT_LOOP.md +6 -4
  10. data/docs/TOOLS.md +22 -2
  11. data/docs/TOOL_ADVANCED.md +20 -7
  12. data/docs/TRACING.md +5 -4
  13. data/lib/riffer/agent/run.rb +2 -4
  14. data/lib/riffer/agent.rb +3 -17
  15. data/lib/riffer/guardrail.rb +1 -1
  16. data/lib/riffer/helpers/identifier.rb +41 -0
  17. data/lib/riffer/messages/tool.rb +2 -1
  18. data/lib/riffer/providers/anthropic.rb +4 -0
  19. data/lib/riffer/providers/base.rb +4 -1
  20. data/lib/riffer/registrable.rb +81 -0
  21. data/lib/riffer/tool.rb +25 -9
  22. data/lib/riffer/tools/response.rb +10 -5
  23. data/lib/riffer/tools/runtime.rb +20 -11
  24. data/lib/riffer/tools/toolable.rb +2 -3
  25. data/lib/riffer/version.rb +1 -1
  26. data/lib/riffer.rb +6 -1
  27. data/sig/generated/riffer/agent.rbs +2 -12
  28. data/sig/generated/riffer/helpers/identifier.rbs +19 -0
  29. data/sig/generated/riffer/messages/tool.rbs +2 -1
  30. data/sig/generated/riffer/providers/base.rbs +2 -0
  31. data/sig/generated/riffer/registrable.rbs +51 -0
  32. data/sig/generated/riffer/tool.rbs +6 -5
  33. data/sig/generated/riffer/tools/response.rbs +8 -4
  34. data/sig/generated/riffer/tools/runtime.rbs +5 -4
  35. data/sig/generated/riffer/tools/toolable.rbs +3 -1
  36. data/sig/generated/riffer.rbs +7 -1
  37. data/sig/manual/riffer/agent.rbs +7 -0
  38. data/sig/manual/riffer/helpers/identifier.rbs +5 -0
  39. data/sig/manual/riffer/tool.rbs +7 -0
  40. metadata +11 -11
  41. data/.agents/architecture.md +0 -265
  42. data/.agents/code-style.md +0 -110
  43. data/.agents/providers.md +0 -54
  44. data/.agents/testing.md +0 -60
  45. data/CLAUDE.md +0 -1
  46. data/lib/riffer/helpers/class_name_converter.rb +0 -22
  47. data/sig/generated/riffer/helpers/class_name_converter.rbs +0 -12
  48. data/sig/manual/riffer/helpers/class_name_converter.rbs +0 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd309de6be56bd5fbe778bbf277b9125dd63d4ce7b360c3e5e85554a944cb81d
4
- data.tar.gz: 965daf888849a0386b2826af572fd95d0a7fb8a4ac6f9f91788acb0254705f8e
3
+ metadata.gz: bed1103d2399d8532635b4041d4a24b8264f160b58a3110436ed67a14380fd56
4
+ data.tar.gz: acdaa65506e0c4887d388663be686bc02ccf668c26db281148efcec9f5772d6b
5
5
  SHA512:
6
- metadata.gz: deedbd28b2c79faceea167422fb69147cb2453d7e06df48c46c2eb1e601068de7b9018026675a973f6de6036373d324d5120fc43269e863dd43ce39b88ee7ea4
7
- data.tar.gz: 2007cc98bd5955ae2db4776e72551d546038deea60be3e397a5838e028cd2901465f2fe9c00d944b18e4ce9508599bcd7603ad61e0e75efaa8264793461cf283
6
+ metadata.gz: 0733aa1731a7206195862e6bfd59936b80cc3219fa913a6afce577a8de9d8f4510ca59316a6bb27c6a778706b6d231ce61cd74ebd9d63f32d2a695453b2bd5ae
7
+ data.tar.gz: da09bd24389e52f79c7cbe3dd977c5ada7bc9f0a923f2d09d2331245b4ca1fd079f9f688da0ab8b36eac6b1a260bd627831dc0c344ae93422f5a006c929a11e3
@@ -10,14 +10,6 @@ Ruby gem framework for building AI-powered agents with LLM provider adapters.
10
10
  - **Model format**: `provider/model` (e.g., `openai/gpt-4`)
11
11
  - **Docs**: when adding a public config option or message attribute, update the matching page in `docs/` (e.g., `docs/CONFIGURATION.md`, `docs/MESSAGES.md`). Adding a NEW page under `docs/` also requires a `docs-site/manifest.yml` entry — the site build fails without one. RDoc ≠ user docs.
12
12
 
13
- ## Topic Guides
14
-
15
- - [Architecture](.agents/architecture.md) - Core components and project structure
16
- - [Testing](.agents/testing.md) - Minitest spec DSL and VCR cassettes
17
- - [Code Style](.agents/code-style.md) - RuboCop, comment, and RDoc conventions
18
- - [Providers](.agents/providers.md) - Adding new LLM provider adapters
19
- - [RBS Inline](.agents/rbs-inline.md) - Type annotations with rbs-inline
20
-
21
13
  ## Commands
22
14
 
23
15
  All wrappers `exec bundle exec …` under the hood.
@@ -0,0 +1,13 @@
1
+ ---
2
+ paths: ["**/*.rb", "**/*.rake", "**/Gemfile"]
3
+ ---
4
+
5
+ # Comments
6
+
7
+ A comment exists to explain a **why** when the code itself cannot — never a **how**, and never a restatement of what the code already says. This bar governs all prose, from inline comments to docstrings.
8
+
9
+ - **Internal and private code** — everything in an application, plus a library's non-exported internals — is self-documenting via clear names and strong types. A comment survives only when it explains something a competent reader cannot recover from the code alone: a non-local constraint, an external-system quirk, a deliberate non-obvious tradeoff. A description of _what_ the code does, or a why that's evident from reading it, gets cut.
10
+ - **A published library's public surface** gets one verb-first sentence per exported symbol ("Serializes the definition to JSON."). An optional second sentence is reserved strictly for a why — a non-obvious constraint or rationale — never a second sentence of how. Needing more than one sentence to say _what_ it does is a smell the symbol does too much.
11
+ - **Types are not prose's job.** Parameters, return values, and field types live in the type system (TypeScript types, rbs-inline `#:` annotations) — never restated in comments that duplicate them.
12
+ - **Markers.** `TODO` / `FIXME` / `HACK` are tracked work and stay; `NOTE` / `REVIEW` meet the same why-bar as any other comment.
13
+ - **No history.** A comment describes the present, never how the code got there — no "was X, now Y", no story of the bug that revealed a constraint. State a still-true constraint in the present tense ("the API returns null for empty results — guard").
@@ -1,128 +1,52 @@
1
+ ---
2
+ paths: ["lib/**/*.rb", "sig/**/*"]
3
+ ---
4
+
1
5
  # RBS Inline
2
6
 
3
7
  Type annotations are added directly in Ruby source files using [rbs-inline](https://github.com/soutaro/rbs-inline).
4
8
 
5
- ## Magic Comment
6
-
7
- rbs-inline only processes a file when `rbs_inline: enabled` is present on line 2. It ships as part of the required header on every `lib/**/*.rb` file — see [Required Header](code-style.md#required-header).
9
+ ## Required Header
8
10
 
9
- ## Annotation Syntax
11
+ Every `lib/**/*.rb` file starts with the two-line header `# frozen_string_literal: true` + `# rbs_inline: enabled` — rbs-inline silently skips any file missing the magic comment on line 2.
10
12
 
11
- The **`#:`** prefix is used — standalone lines above methods (type signatures) or inline on the same line (attributes, constants).
13
+ ## Annotation Conventions
12
14
 
13
- ### Method Parameters and Return Types
15
+ The **`#:`** prefix is used — standalone lines above methods (RBS method signatures) or inline on the same line (attributes, constants).
14
16
 
15
- Use a single `#:` line above the method with the RBS method signature:
17
+ `# @rbs` is used **only** for ivar declarations; everything else uses `#:`. The `#:` shorthand on an assignment is a Steep _assertion_ — it types the expression but does **not declare the ivar**. To declare an ivar's type, use a `# @rbs` comment inside the class body:
16
18
 
17
19
  ```ruby
18
- #: (String, Integer) -> bool
19
- def valid?(name, age)
20
- ```
20
+ class Riffer::Agent::Session
21
+ # @rbs @callbacks: Array[^(Riffer::Messages::Base) -> void] # instance ivar
22
+ end
21
23
 
22
- #### Parameter Mapping
24
+ module Riffer
25
+ # @rbs self.@config: Riffer::Config? # class/module-level ivar
26
+ end
27
+ ```
23
28
 
24
- | Ruby param | RBS signature |
25
- | ---------------------------- | ------------------------ |
26
- | `def foo(x)` | `(Type)` |
27
- | `def foo(x = nil)` | `(?Type?)` |
28
- | `def foo(x = val)` | `(?Type)` |
29
- | `def foo(x:)` | `(x: Type)` |
30
- | `def foo(x: nil)` | `(?x: Type?)` |
31
- | `def foo(x: val)` | `(?x: Type)` |
32
- | `def foo(*args)` | `(*untyped)` |
33
- | `def foo(**kwargs)` | `(**untyped)` |
34
- | `def foo(&block)` (required) | `() { (Type) -> void }` |
35
- | `def foo(&block)` (optional) | `() ?{ (Type) -> void }` |
36
- | `def foo(...)` | `(*untyped, **untyped)` |
29
+ ## RDoc Conventions
37
30
 
38
- #### Examples
31
+ **The `#--` stop directive.** Place `#--` on the line immediately before a **standalone** `#:` type annotation. Without it, RDoc treats `#:` as a label-list marker and corrupts the preceding description into a `<pre>` block. Inline `#:` on the same line as code (attributes, constants) does not need it.
39
32
 
40
33
  ```ruby
41
- # No parameters
42
- #: () -> String
43
- def name
44
-
45
- # Positional parameters
46
- #: (String, Integer) -> bool
47
- def valid?(name, age)
48
-
49
- # Optional positional parameter
50
- #: (?String?) -> String
51
- def self.identifier(value = nil)
52
-
53
- # Required keyword parameters
54
- #: (input: String, output: String) -> Riffer::Evals::Result
55
- def evaluate(input:, output:)
56
-
57
- # Mixed keyword parameters (required + optional)
58
- #: (input: String, output: String, ?context: Hash[Symbol, untyped]?) -> Riffer::Evals::Result
59
- def evaluate(input:, output:, context: nil)
60
-
61
- # Positional + keyword parameters
62
- #: (String, ?context: Hash[Symbol, untyped]?) -> String
63
- def generate(prompt, context: nil)
64
-
65
- # Splat/double-splat
66
- #: (**untyped) -> void
67
- def initialize(**options)
68
-
69
- # Forward arguments
70
- #: (*untyped, **untyped) -> String
71
- def self.generate(...)
72
-
73
- # Block parameter (required)
74
- #: () { (Riffer::Messages::Base) -> void } -> self
75
- def on_message(&block)
34
+ # Serializes the agent definition to a transferable JSON payload.
35
+ #--
36
+ #: (Riffer::Agent) -> String
37
+ def serialize(agent)
76
38
 
77
- # Block parameter (optional)
78
- #: () ?{ (Riffer::Config) -> void } -> void
79
- def configure(&block)
80
- ```
81
-
82
- ### Attributes
83
-
84
- ```ruby
39
+ # The agent's display name.
85
40
  attr_reader :name #: String
86
- attr_reader :items #: Array[String]
87
41
  ```
88
42
 
89
- ### Constants
90
-
91
- ```ruby
92
- VERSION = "1.0.0" #: String
93
- DEFAULTS = {}.freeze #: Hash[Symbol, untyped]
94
- ```
95
-
96
- ### Instance variables
97
-
98
- The `#:` shorthand on an assignment is a Steep _assertion_ — it types the expression but does
99
- **not declare the ivar**. To declare an ivar's type, use a `# @rbs` comment inside the class
100
- body. `# @rbs` is used **only** for ivar declarations in this codebase; everything else uses
101
- `#:`.
43
+ **Raises.** Document a raise **only when it's part of the caller's contract** — something a caller should reasonably anticipate and handle. Skip programmer-error guards and "should never happen" assertions. When the raise condition merely restates the declared `#:` type, phrase it by intent ("Raises Riffer::ArgumentError on an invalid value") rather than re-listing the type union.
102
44
 
103
- ```ruby
104
- class Riffer::Agent::Session
105
- # @rbs @callbacks: Array[^(Riffer::Messages::Base) -> void] # instance ivar
106
- end
107
-
108
- module Riffer
109
- # @rbs self.@config: Riffer::Config? # class/module-level ivar
110
- end
111
- ```
45
+ **Examples.** Include an example only when a **consumer is likely to use the thing themselves** — a public entry point they construct, subclass, or call. Keep them sparing and write them as indented code blocks (2 extra spaces of indent). Usage walkthroughs belong in `docs/`.
112
46
 
113
- ## Common Type Patterns
47
+ **Inline code formatting.** Use `+word+` for single-word inline code; for multi-word expressions (spaces, colons, brackets) use `<tt>multi word expression</tt>`.
114
48
 
115
- | Pattern | Meaning |
116
- | ------------------------- | --------------------------- |
117
- | `String?` | Optional (String or nil) |
118
- | `(String \| Integer)` | Union type |
119
- | `Array[String]` | Typed array |
120
- | `Hash[Symbol, untyped]` | Typed hash |
121
- | `^(String) -> void` | Block/proc type |
122
- | `singleton(Riffer::Tool)` | Class object (not instance) |
123
- | `bool` | Boolean (true or false) |
124
- | `untyped` | Any type |
125
- | `void` | No meaningful return |
49
+ **Internal APIs.** Mark with `# :nodoc:` to exclude from generated documentation.
126
50
 
127
51
  ## Optional-dependency types (consumer-safe signatures)
128
52
 
@@ -155,7 +79,7 @@ end
155
79
  ### Where stubs and stdlib deps live
156
80
 
157
81
  - `sig/_private/` — signatures that must **not** ship. RBS **skips** `_`-prefixed directories in library mode, so consumers never load them; riffer's own `steep check` does (via the `Steepfile`). Two kinds, by predictable path: external-gem signatures are named by gem at the top level (`async.rbs`, `mcp.rbs`, `zeitwerk.rbs`, `openai.rbs`, `anthropic.rbs`, `aws-sdk-core/*` — full stubs for RBS-less gems plus arity patches for the provider SDKs); riffer's own hidden stubs mirror `lib/` under `riffer/` (e.g. `riffer/providers/anthropic.rbs` narrows the private `client` method to the SDK-typed client).
158
- - `sig/manual/` — hand-written riffer-only signatures that are **safe to ship**, for the few things rbs-inline can't generate _at all_ (mirroring `lib/`). In practice that's `extend self` modules (`riffer/agent/run.rbs`, `riffer/helpers/call_or_value.rbs`) and modeling an include applied dynamically (`riffer/tools/toolable.rbs`). SDK-free ivars are **not** hand-written here — declare them inline with `# @rbs` (see "Instance variables"). SDK-typed signatures can't ship, so they go in `_private/riffer/providers/` (the narrowed `client`).
82
+ - `sig/manual/` — hand-written riffer-only signatures that are **safe to ship**, for the few things rbs-inline can't generate _at all_ (mirroring `lib/`). In practice that's `extend self` modules (`riffer/agent/run.rbs`, `riffer/helpers/call_or_value.rbs`) and modeling an include applied dynamically (`riffer/tools/toolable.rbs`). SDK-free ivars are **not** hand-written here — declare them inline with `# @rbs` (see "Annotation Conventions"). SDK-typed signatures can't ship, so they go in `_private/riffer/providers/` (the narrowed `client`).
159
83
  - `sig/manifest.yaml` — declares the **stdlib** RBS the shipped sigs reference (`uri`, `net-http`) so `rbs -r riffer` resolves them.
160
84
 
161
85
  ## Workflow
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.41.0"
2
+ ".": "0.43.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,28 @@ 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
+
19
+ ## [0.42.0](https://github.com/janeapp/riffer/compare/riffer/v0.41.0...riffer/v0.42.0) (2026-08-25)
20
+
21
+
22
+ ### ⚠ BREAKING CHANGES
23
+
24
+ * duplicate identifiers among named direct subclasses of Riffer::Tool or Riffer::Agent now raise Riffer::DuplicateIdentifierError on lookup. Agent.find/.all (and the new Tool.find/.all) cover named direct subclasses only - anonymous classes are excluded even with an explicit identifier. Riffer::Helpers::ClassNameConverter is removed; use Riffer::Helpers::Identifier.derive (the separator: keyword is dropped).
25
+
26
+ ### Features
27
+
28
+ * one identifier registry pattern for tools and agents ([#406](https://github.com/janeapp/riffer/issues/406)) ([72fa7d6](https://github.com/janeapp/riffer/commit/72fa7d6377f903e4a88f9f5ec0e27e51b46dc293))
29
+
8
30
  ## [0.41.0](https://github.com/janeapp/riffer/compare/riffer/v0.40.0...riffer/v0.41.0) (2026-08-14)
9
31
 
10
32
 
data/docs/AGENTS.md CHANGED
@@ -306,6 +306,27 @@ agent.generate('Hello')
306
306
 
307
307
  When `config:` is supplied, the class-level configuration is ignored for that instance.
308
308
 
309
+ ## Looking Up Agents
310
+
311
+ Look up an agent by identifier with `Riffer::Agent.find`, or list every agent with `Riffer::Agent.all`:
312
+
313
+ ```ruby
314
+ class SupportAgent < Riffer::Agent
315
+ model 'openai/gpt-5-mini'
316
+ end
317
+
318
+ Riffer::Agent.find('support_agent') # => SupportAgent
319
+ Riffer::Agent.find(:support_agent) # symbols work too
320
+ Riffer::Agent.find('missing') # => nil
321
+ Riffer::Agent.all # => [SupportAgent, ...]
322
+ ```
323
+
324
+ Only **named direct subclasses** are found:
325
+
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`.
328
+ - Two subclasses sharing an identifier raise `Riffer::DuplicateIdentifierError` at the first lookup.
329
+
309
330
  ## Per-Call Tags
310
331
 
311
332
  `#generate` and `#stream` accept an optional `tags:` hash — a flat map of attribution labels scoped to that single call (for cost/usage attribution, filtering audit logs, slicing telemetry). It is **per-call only**.
@@ -348,10 +369,10 @@ Riffer does not validate tag count, key/value length, or charset — it forwards
348
369
 
349
370
  ## Expand Your Agent
350
371
 
351
- | Goal | Feature | Guide |
352
- | ----------------------------- | ----------------- | -------------------------------------------------------------------- |
372
+ | Goal | Feature | Guide |
373
+ | ----------------------------- | ----------------- | ----------------------------------------------------------------- |
353
374
  | Call APIs or run functions | Tools | [Tools](TOOLS.md) |
354
- | Return structured JSON | Structured Output | [structured_output](#structured_output) |
375
+ | Return structured JSON | Structured Output | [structured_output](#structured_output) |
355
376
  | Validate or filter content | Guardrails | [Guardrails](GUARDRAILS.md) |
356
377
  | Measure output quality | Evals | [Evals](EVALS.md) |
357
378
  | Add packaged capabilities | Skills | [Skills](SKILLS.md) |
@@ -347,7 +347,7 @@ See [Messages — Structured Output on Messages](MESSAGES.md#structured-output-o
347
347
 
348
348
  ### find
349
349
 
350
- Find an agent class by identifier:
350
+ Find an agent class by identifier (named direct subclasses only — see [Agents — Looking Up Agents](AGENTS.md#looking-up-agents)):
351
351
 
352
352
  ```ruby
353
353
  agent_class = Riffer::Agent.find('my_agent')
@@ -356,7 +356,7 @@ agent = agent_class.new
356
356
 
357
357
  ### all
358
358
 
359
- List all agent subclasses:
359
+ List all registered agent subclasses (named direct subclasses only):
360
360
 
361
361
  ```ruby
362
362
  Riffer::Agent.all.each do |agent_class|
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
@@ -108,7 +108,7 @@ Options:
108
108
  | `String` | `string` |
109
109
  | `Integer` | `integer` |
110
110
  | `Float` | `number` |
111
- | `Riffer::Params::Boolean` | `boolean` |
111
+ | `Riffer::Params::Boolean` | `boolean` |
112
112
  | `TrueClass` / `FalseClass` | `boolean` |
113
113
  | `Array` | `array` |
114
114
  | `Hash` | `object` |
@@ -143,6 +143,23 @@ class CreateOrderTool < Riffer::Tool
143
143
  end
144
144
  ```
145
145
 
146
+ ## Looking Up Tools
147
+
148
+ Look up a tool by identifier with `Riffer::Tool.find`, or list every tool with `Riffer::Tool.all`:
149
+
150
+ ```ruby
151
+ Riffer::Tool.find('kb_search') # => SearchTool
152
+ Riffer::Tool.find(:kb_search) # symbols work too
153
+ Riffer::Tool.find('missing') # => nil
154
+ Riffer::Tool.all # => [SearchTool, ...]
155
+ ```
156
+
157
+ Only **named direct subclasses** are found:
158
+
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`.
161
+ - Two subclasses sharing an identifier raise `Riffer::DuplicateIdentifierError` at the first lookup.
162
+
146
163
  ## The call Method
147
164
 
148
165
  Every tool must implement the `call` method and return a `Riffer::Tools::Response`:
@@ -252,7 +269,7 @@ error("Service unavailable", type: :service_error)
252
269
  error("Rate limit exceeded", type: :rate_limit)
253
270
  ```
254
271
 
255
- 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.
256
273
 
257
274
  ### Using Riffer::Tools::Response Directly
258
275
 
@@ -280,4 +297,7 @@ error_response.success? # => false
280
297
  error_response.error? # => true
281
298
  error_response.error_message # => "failed"
282
299
  error_response.error_type # => :not_found
300
+ error_response.exception # => nil
283
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
 
@@ -465,10 +465,8 @@ module Riffer::Agent::Run
465
465
  tripwire = response.tripwire
466
466
  return unless tripwire
467
467
 
468
- class_name = tripwire.guardrail.name
469
- if class_name
470
- span.set_attribute("riffer.tripwire.guardrail", Riffer::Helpers::ClassNameConverter.convert(class_name))
471
- end
468
+ identifier = Riffer::Helpers::Identifier.for(tripwire.guardrail)
469
+ span.set_attribute("riffer.tripwire.guardrail", identifier) unless identifier.empty?
472
470
  span.set_attribute("riffer.tripwire.reason", tripwire.reason)
473
471
  span.set_attribute("riffer.tripwire.phase", tripwire.phase.to_s)
474
472
  end
data/lib/riffer/agent.rb CHANGED
@@ -15,6 +15,8 @@ require "json"
15
15
  # agent.generate('Hello!')
16
16
  #
17
17
  class Riffer::Agent
18
+ extend Riffer::Registrable
19
+
18
20
  # @rbs self.@config: Riffer::Agent::Config?
19
21
 
20
22
  INTERRUPT_MAX_STEPS = :max_steps #: Symbol
@@ -31,7 +33,7 @@ class Riffer::Agent
31
33
  #--
32
34
  #: (?String?) -> String
33
35
  def self.identifier(value = nil)
34
- value.nil? ? (config.identifier || Riffer::Helpers::ClassNameConverter.convert(name)) : (config.identifier = value)
36
+ value.nil? ? (config.identifier || Riffer::Helpers::Identifier.for(self)) : (config.identifier = value)
35
37
  end
36
38
 
37
39
  # Gets or sets the model string (e.g., "openai/gpt-4o").
@@ -144,22 +146,6 @@ class Riffer::Agent
144
146
  config.skills_config
145
147
  end
146
148
 
147
- # Finds an agent class by identifier.
148
- #
149
- #--
150
- #: (String) -> singleton(Riffer::Agent)?
151
- def self.find(identifier)
152
- all.find { |agent_class| agent_class.identifier == identifier.to_s }
153
- end
154
-
155
- # Returns all agent subclasses.
156
- #
157
- #--
158
- #: () -> Array[singleton(Riffer::Agent)]
159
- def self.all
160
- subclasses #: Array[singleton(Riffer::Agent)]
161
- end
162
-
163
149
  # Generates a response using a new agent instance.
164
150
  #--
165
151
  #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
@@ -36,7 +36,7 @@ class Riffer::Guardrail
36
36
  #--
37
37
  #: () -> String
38
38
  def name
39
- Riffer::Helpers::ClassNameConverter.convert(self.class.name)
39
+ Riffer::Helpers::Identifier.for(self.class)
40
40
  end
41
41
 
42
42
  protected
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Helper module for deriving snake_case identifiers from class names.
5
+ module Riffer::Helpers::Identifier
6
+ extend self
7
+
8
+ # Derives a snake_case identifier from a class name string.
9
+ #
10
+ #--
11
+ #: (String?) -> String
12
+ def derive(class_name)
13
+ class_name.
14
+ to_s.
15
+ gsub("::", "/").
16
+ gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').
17
+ gsub(/([a-z\d])([A-Z])/, '\1_\2').
18
+ downcase
19
+ end
20
+
21
+ # Derives and memoizes the identifier for a class or module. Anonymous
22
+ # classes return "" without caching, so a class named later still derives its
23
+ # real identifier — a guard that must travel with the cache, so callers never
24
+ # memoize their own.
25
+ #
26
+ #--
27
+ #: (Module) -> String
28
+ def for(klass)
29
+ cached = klass.instance_variable_get(:@derived_identifier) #: String?
30
+ return cached if cached
31
+
32
+ # Tool classes shadow Module#name with the identifier DSL, so the real
33
+ # class-path name must come from Module's own implementation.
34
+ real_name = Module.instance_method(:name).bind_call(klass) #: String?
35
+ return "" if real_name.nil?
36
+
37
+ derived = derive(real_name)
38
+ klass.instance_variable_set(:@derived_identifier, derived)
39
+ derived
40
+ end
41
+ end
@@ -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
  #--
@@ -292,6 +292,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
292
292
  #--
293
293
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
294
294
  def handle_input_json_event(event, state:, yielder:)
295
+ # server_tool_use (web_search) input streams through the raw-event path
296
+ # (handle_raw_content_block_delta) and must not surface as tool-call deltas.
297
+ return if state[:web_search_json]
298
+
295
299
  state[:tool_call] = { id: nil, name: nil, arguments: +"" } if state[:tool_call].nil?
296
300
  state[:tool_call][:arguments] << event.partial_json
297
301
  yielder << Riffer::StreamEvents::ToolCallDelta.new(
@@ -11,6 +11,7 @@ class Riffer::Providers::Base
11
11
  # @rbs @current_tools: Array[singleton(Riffer::Tool)]
12
12
  # @rbs @current_model: String?
13
13
  # @rbs @client: untyped
14
+ # @rbs self.@semconv_provider_name: String?
14
15
 
15
16
  WIRE_SEPARATOR = "__" #: String
16
17
 
@@ -30,9 +31,11 @@ class Riffer::Providers::Base
30
31
  #: () -> String
31
32
  def self.semconv_provider_name
32
33
  class_name = name
34
+ # Anonymous classes stay uncached: a class assigned to a constant
35
+ # later must pick up its real name, not a frozen "unknown".
33
36
  return "unknown" unless class_name
34
37
 
35
- Riffer::Helpers::ClassNameConverter.convert(class_name.split("::").last.to_s)
38
+ @semconv_provider_name ||= Riffer::Helpers::Identifier.derive(class_name.split("::").last)
36
39
  end
37
40
 
38
41
  # Generates text using the provider.