riffer 0.47.1 → 0.48.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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/rbs-inline.md +5 -1
  3. data/.release-please-manifest.json +1 -1
  4. data/CHANGELOG.md +31 -0
  5. data/Rakefile +1 -1
  6. data/Steepfile +1 -15
  7. data/docs/AGENTS.md +37 -3
  8. data/docs/AGENT_LIFECYCLE.md +1 -0
  9. data/docs/CONFIGURATION.md +1 -1
  10. data/docs/EVALS.md +2 -1
  11. data/docs/MCP.md +0 -4
  12. data/docs/MESSAGES.md +85 -17
  13. data/docs/STREAM_EVENTS.md +31 -4
  14. data/docs/TRACING.md +1 -1
  15. data/docs/providers/AMAZON_BEDROCK.md +1 -1
  16. data/docs/providers/CUSTOM_PROVIDERS.md +58 -4
  17. data/docs/providers/GEMINI.md +1 -1
  18. data/docs/providers/MOCK_PROVIDER.md +17 -0
  19. data/lib/riffer/agent/config.rb +40 -16
  20. data/lib/riffer/agent/outcome.rb +2 -2
  21. data/lib/riffer/agent/response.rb +16 -9
  22. data/lib/riffer/agent/run.rb +24 -9
  23. data/lib/riffer/agent/session.rb +2 -1
  24. data/lib/riffer/agent/structured_output/result.rb +2 -2
  25. data/lib/riffer/agent/structured_output.rb +1 -1
  26. data/lib/riffer/agent.rb +40 -16
  27. data/lib/riffer/config.rb +31 -31
  28. data/lib/riffer/evals/evaluator.rb +29 -2
  29. data/lib/riffer/evals/judge.rb +13 -8
  30. data/lib/riffer/evals/result.rb +6 -6
  31. data/lib/riffer/evals/run_result.rb +1 -1
  32. data/lib/riffer/evals/scenario_result.rb +6 -6
  33. data/lib/riffer/files/resolver.rb +3 -3
  34. data/lib/riffer/guardrails/modification.rb +3 -3
  35. data/lib/riffer/guardrails/result.rb +3 -3
  36. data/lib/riffer/guardrails/runner.rb +4 -4
  37. data/lib/riffer/guardrails/tripwire.rb +4 -4
  38. data/lib/riffer/helpers/deep_dup.rb +42 -0
  39. data/lib/riffer/mcp/manifest.rb +5 -5
  40. data/lib/riffer/mcp/registration.rb +1 -1
  41. data/lib/riffer/mcp/search_tool.rb +1 -1
  42. data/lib/riffer/messages/assistant/reasoning_part.rb +90 -0
  43. data/lib/riffer/messages/assistant/tool_call.rb +63 -0
  44. data/lib/riffer/messages/assistant.rb +55 -7
  45. data/lib/riffer/messages/base.rb +7 -25
  46. data/lib/riffer/messages/system.rb +10 -0
  47. data/lib/riffer/messages/tool.rb +21 -4
  48. data/lib/riffer/messages/{file_part.rb → user/file_part.rb} +18 -17
  49. data/lib/riffer/messages/user.rb +13 -2
  50. data/lib/riffer/params/param.rb +20 -8
  51. data/lib/riffer/params.rb +10 -1
  52. data/lib/riffer/providers/amazon_bedrock.rb +34 -6
  53. data/lib/riffer/providers/anthropic.rb +9 -2
  54. data/lib/riffer/providers/base.rb +28 -10
  55. data/lib/riffer/providers/finish_reason.rb +2 -2
  56. data/lib/riffer/providers/gemini.rb +2 -2
  57. data/lib/riffer/providers/mock.rb +19 -3
  58. data/lib/riffer/providers/open_ai.rb +14 -5
  59. data/lib/riffer/providers/open_router.rb +6 -5
  60. data/lib/riffer/providers/repository.rb +0 -4
  61. data/lib/riffer/providers/token_usage.rb +21 -5
  62. data/lib/riffer/skills/adapter.rb +1 -1
  63. data/lib/riffer/skills/config.rb +12 -0
  64. data/lib/riffer/skills/context.rb +4 -7
  65. data/lib/riffer/skills/filesystem_backend.rb +1 -2
  66. data/lib/riffer/skills/frontmatter.rb +4 -4
  67. data/lib/riffer/skills/xml_adapter.rb +1 -1
  68. data/lib/riffer/stream_events/base.rb +1 -1
  69. data/lib/riffer/stream_events/finish_reason_done.rb +2 -2
  70. data/lib/riffer/stream_events/guardrail_modification.rb +1 -1
  71. data/lib/riffer/stream_events/guardrail_tripwire.rb +1 -1
  72. data/lib/riffer/stream_events/interrupt.rb +2 -2
  73. data/lib/riffer/stream_events/reasoning_delta.rb +1 -1
  74. data/lib/riffer/stream_events/reasoning_done.rb +10 -8
  75. data/lib/riffer/stream_events/skill_activation.rb +1 -1
  76. data/lib/riffer/stream_events/text_delta.rb +1 -1
  77. data/lib/riffer/stream_events/text_done.rb +1 -1
  78. data/lib/riffer/stream_events/token_usage_done.rb +1 -1
  79. data/lib/riffer/stream_events/tool_call_delta.rb +3 -3
  80. data/lib/riffer/stream_events/tool_call_done.rb +4 -4
  81. data/lib/riffer/stream_events/web_search_done.rb +2 -2
  82. data/lib/riffer/stream_events/web_search_status.rb +3 -3
  83. data/lib/riffer/tools/response.rb +4 -4
  84. data/lib/riffer/tools/runtime.rb +3 -4
  85. data/lib/riffer/tracing/capture.rb +2 -4
  86. data/lib/riffer/tracing/stream_recorder.rb +6 -6
  87. data/lib/riffer/version.rb +1 -1
  88. data/lib/riffer.rb +7 -0
  89. data/rbs_collection.lock.yaml +320 -0
  90. data/rbs_collection.yaml +12 -0
  91. data/sig/_private/anthropic.rbs +5 -4
  92. data/sig/_private/aws-sdk-core/event_error.rbs +15 -0
  93. data/sig/generated/riffer/agent/config.rbs +19 -5
  94. data/sig/generated/riffer/agent/response.rbs +6 -1
  95. data/sig/generated/riffer/agent/run.rbs +15 -8
  96. data/sig/generated/riffer/agent.rbs +25 -9
  97. data/sig/generated/riffer/config.rbs +1 -1
  98. data/sig/generated/riffer/evals/evaluator.rbs +20 -1
  99. data/sig/generated/riffer/evals/judge.rbs +5 -2
  100. data/sig/generated/riffer/files/resolver.rbs +6 -6
  101. data/sig/generated/riffer/helpers/deep_dup.rbs +21 -0
  102. data/sig/generated/riffer/messages/assistant/reasoning_part.rbs +58 -0
  103. data/sig/generated/riffer/messages/assistant/tool_call.rbs +42 -0
  104. data/sig/generated/riffer/messages/assistant.rbs +24 -10
  105. data/sig/generated/riffer/messages/system.rbs +6 -0
  106. data/sig/generated/riffer/messages/tool.rbs +6 -0
  107. data/sig/generated/riffer/messages/{file_part.rbs → user/file_part.rbs} +11 -6
  108. data/sig/generated/riffer/messages/user.rbs +9 -3
  109. data/sig/generated/riffer/params/param.rbs +7 -0
  110. data/sig/generated/riffer/params.rbs +6 -0
  111. data/sig/generated/riffer/providers/amazon_bedrock.rbs +14 -5
  112. data/sig/generated/riffer/providers/anthropic.rbs +2 -2
  113. data/sig/generated/riffer/providers/base.rbs +22 -10
  114. data/sig/generated/riffer/providers/gemini.rbs +4 -4
  115. data/sig/generated/riffer/providers/mock.rbs +7 -2
  116. data/sig/generated/riffer/providers/open_ai.rbs +4 -4
  117. data/sig/generated/riffer/providers/open_router.rbs +4 -4
  118. data/sig/generated/riffer/providers/repository.rbs +0 -2
  119. data/sig/generated/riffer/providers/token_usage.rbs +6 -0
  120. data/sig/generated/riffer/skills/config.rbs +9 -0
  121. data/sig/generated/riffer/stream_events/reasoning_done.rbs +8 -6
  122. data/sig/generated/riffer/tools/runtime.rbs +2 -2
  123. data/sig/generated/riffer/tracing/capture.rbs +4 -4
  124. data/sig/generated/riffer.rbs +8 -0
  125. data/sig/manual/riffer/helpers/deep_dup.rbs +5 -0
  126. metadata +13 -5
  127. data/sig/_private/async.rbs +0 -28
  128. data/sig/_private/minitest.rbs +0 -9
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4ac69c8c20b892c53242b0fb792aee2ee1ce5da1ab02b1c53536fa8d1c006f08
4
- data.tar.gz: 0fa02ebc739e1968167e517a41d4f0a2cabff5a5845862ac78980d207638c6cd
3
+ metadata.gz: 5975a19de149eced0122ccf94f08666f66377c395b3a414db48158fcba352807
4
+ data.tar.gz: fe48ac55158f5bd1f4818651988b68ed2e45fa985c1ebe5a386d636decd15ff2
5
5
  SHA512:
6
- metadata.gz: 5c0363ce9dad27ccfc899d6c7f4e71123eaa9ae5ceec8e50387575f0642c7696f07de37cb987c45099aee999fe5ac39c8635f07f36efb784c3341bd8a4c6a6aa
7
- data.tar.gz: 4b5d6209255a46d9abd52275021709740bf0e87676c20c3bd9240f12acddc7768a050391530970c2a2ed407dbdd27fb50a1b03e43caff8361ad6f28cc67884db
6
+ metadata.gz: 7e506427c0b0272fb2c6f044def74b74d423e92bb23fbe95e02c5678d48b8b98ff2e8920807b23e4d8fb2db94d509937643813d9248eb109a51d03559a5ecd48
7
+ data.tar.gz: 3553da8e73fa137a4070baac5548fbd3a345316f0941e9f8b8490d2790d17a5e47b5eb154de6613897874a0a2bd3adaff4b1167d55fe21b99a1d0840d9786733
@@ -78,7 +78,11 @@ end
78
78
 
79
79
  ### Where stubs and stdlib deps live
80
80
 
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).
81
+ Dependency signatures come from [`rbs collection`](https://github.com/ruby/gem_rbs_collection): `rbs_collection.yaml` pins the upstream repo to a commit SHA, the gem set is derived from `Gemfile.lock`, and `rbs_collection.lock.yaml` is committed (the installed `.gem_rbs_collection/` is gitignored). Steep auto-detects the config, so the `Steepfile` declares no `library` lines. `yaml` is the one hand-listed entry under `gems:` — it's stdlib, so it never appears in `Gemfile.lock`.
82
+
83
+ `bin/typecheck` runs `rbs collection update` before `steep check`, keeping the lock in sync with `Gemfile.lock`; CI fails on lock drift. So after a Dependabot gem bump, run `bin/typecheck` and commit the refreshed lock. To pull newer upstream stubs, bump `revision` in `rbs_collection.yaml` and run `bin/typecheck` — pinning is what makes `update` deterministic.
84
+
85
+ - `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 — full stubs for the gems upstream has nothing for (`mcp.rbs`, `zeitwerk.rbs`, `opentelemetry.rbs`, `rspec.rbs`, `aws-sdk-core/*`) plus arity patches for the provider SDKs (`openai.rbs`, `anthropic.rbs`); 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). A stub must be **deleted** once the collection covers that gem, or RBS raises `DuplicatedMethodDefinitionError`.
82
86
  - `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`).
83
87
  - `sig/manifest.yaml` — declares the **stdlib** RBS the shipped sigs reference (`uri`, `net-http`) so `rbs -r riffer` resolves them.
84
88
 
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.47.1"
2
+ ".": "0.48.0"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ 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.48.0](https://github.com/janeapp/riffer/compare/riffer/v0.47.2...riffer/v0.48.0) (2026-09-23)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **messages:** Riffer::Messages::FilePart is renamed to Riffer::Messages::User::FilePart with no alias. ToolCall no longer responds to Struct-only methods such as [] or to_a; use its readers instead.
14
+
15
+ ### Features
16
+
17
+ * **agent:** inherit configuration on subclass ([#440](https://github.com/janeapp/riffer/issues/440)) ([a45c457](https://github.com/janeapp/riffer/commit/a45c45756108cfac7970e88b9f6f5b64638896c8))
18
+ * identify agents and evals with default tags ([#452](https://github.com/janeapp/riffer/issues/452)) ([22ab54c](https://github.com/janeapp/riffer/commit/22ab54c5225a45c38e255b1a2aa32be0ac28cb12))
19
+ * **messages:** persist reasoning on assistant messages ([#446](https://github.com/janeapp/riffer/issues/446)) ([7fc64ab](https://github.com/janeapp/riffer/commit/7fc64ab416feb47ccff575518378c58bbcc74d63))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **messages:** preserve tool errors and token usage through from_hash ([#450](https://github.com/janeapp/riffer/issues/450)) ([6e2bd90](https://github.com/janeapp/riffer/commit/6e2bd90926d7c3c071ac95f708875652268c767f))
25
+
26
+
27
+ ### Code Refactoring
28
+
29
+ * **messages:** make nested message objects follow one pattern ([#448](https://github.com/janeapp/riffer/issues/448)) ([e9e63db](https://github.com/janeapp/riffer/commit/e9e63dba16121692f734b476569b062c09cc959d))
30
+
31
+ ## [0.47.2](https://github.com/janeapp/riffer/compare/riffer/v0.47.1...riffer/v0.47.2) (2026-09-21)
32
+
33
+
34
+ ### Bug Fixes
35
+
36
+ * **providers:** raise IncompleteStreamError when a stream ends without its terminal event ([#445](https://github.com/janeapp/riffer/issues/445)) ([d142e23](https://github.com/janeapp/riffer/commit/d142e23e80d30ce73e81474eee35c71df1856c40))
37
+ * stop relying on the transitive cgi gem for CGI.escapeHTML ([#443](https://github.com/janeapp/riffer/issues/443)) ([821f3e3](https://github.com/janeapp/riffer/commit/821f3e3e405845b2cc275f86344e526cc2cf183a))
38
+
8
39
  ## [0.47.1](https://github.com/janeapp/riffer/compare/riffer/v0.47.0...riffer/v0.47.1) (2026-09-11)
9
40
 
10
41
 
data/Rakefile CHANGED
@@ -58,7 +58,7 @@ end
58
58
  namespace :steep do
59
59
  desc "Run Steep type checker"
60
60
  task :check do
61
- sh "bundle exec steep check"
61
+ sh "bin/typecheck"
62
62
  end
63
63
  end
64
64
 
data/Steepfile CHANGED
@@ -9,19 +9,5 @@ target :lib do
9
9
 
10
10
  check "lib"
11
11
 
12
- library "anthropic"
13
- library "aws-sdk-bedrockruntime"
14
- library "aws-sdk-core"
15
- library "base64"
16
- library "cgi"
17
- library "digest"
18
- library "json"
19
- library "logger"
20
- library "net-http"
21
- library "openai"
22
- library "securerandom"
23
- library "uri"
24
- library "yaml"
25
-
26
- configure_code_diagnostics(D::Ruby.strict)
12
+ configure_code_diagnostics(D::Ruby.all_error)
27
13
  end
data/docs/AGENTS.md CHANGED
@@ -126,7 +126,7 @@ end
126
126
 
127
127
  ### use_mcp
128
128
 
129
- Loads tools from registered [MCP](MCP.md) servers by tag. Like `uses_tools`, **`use_mcp` is not inherited**—add it on each subclass that should include MCP tools.
129
+ Loads tools from registered [MCP](MCP.md) servers by tag.
130
130
 
131
131
  ### model_options
132
132
 
@@ -265,7 +265,7 @@ class MyAgent < Riffer::Agent
265
265
  end
266
266
  ```
267
267
 
268
- Accepts a `Riffer::Tools::Runtime` subclass, a `Riffer::Tools::Runtime` instance, or a `Proc`. When unset, defaults to `Riffer.config.tool_runtime` (captured at agent class definition time). See [Tools — Tool Runtime](TOOL_ADVANCED.md#tool-runtime-experimental) for details.
268
+ Accepts a `Riffer::Tools::Runtime` subclass, a `Riffer::Tools::Runtime` instance, or a `Proc`. When unset, reads `Riffer.config.tool_runtime` at the point of use, so an agent that declares none follows a later change to the global. See [Tools — Tool Runtime](TOOL_ADVANCED.md#tool-runtime-experimental) for details.
269
269
 
270
270
  ### guardrail
271
271
 
@@ -304,6 +304,27 @@ MyAgent.config.max_steps # => 8
304
304
 
305
305
  The DSL methods read and mutate this Config in place.
306
306
 
307
+ ### Inheritance
308
+
309
+ A subclass starts from a copy of its parent's Config, so it inherits every setting the parent declared and overrides only what its own body declares:
310
+
311
+ ```ruby
312
+ class BaseAgent < Riffer::Agent
313
+ model 'openai/gpt-5-mini'
314
+ max_steps 8
315
+ end
316
+
317
+ class TerseAgent < BaseAgent
318
+ max_steps 2
319
+ end
320
+
321
+ TerseAgent.config.model # => 'openai/gpt-5-mini'
322
+ TerseAgent.config.max_steps # => 2
323
+ BaseAgent.config.max_steps # => 8
324
+ ```
325
+
326
+ `identifier` is not inherited. A subclass derives its own from its class name, since two classes claiming one identifier would raise `Riffer::DuplicateIdentifierError` at the first lookup.
327
+
307
328
  For advanced composition or testing, build a Config directly and pass it via `config:` to bypass class-level DSL entirely:
308
329
 
309
330
  ```ruby
@@ -372,13 +393,24 @@ agent.generate("Summarize this ticket.",
372
393
  agent.stream("...", tags: {team: "growth", environment: "production"})
373
394
  ```
374
395
 
375
- Keys and values may be `String` or `Symbol`; both are stringified, and entries with a `nil` value are dropped. Passing a non-`Hash` raises `Riffer::ArgumentError`. An omitted or empty `tags:` is a complete no-op.
396
+ Keys and values may be `String` or `Symbol`; both are stringified, and entries with a `nil` value are dropped.
376
397
 
377
398
  Tags propagate to **two** places:
378
399
 
379
400
  1. The provider's native per-request metadata field (see the mapping below).
380
401
  2. Observability — stamped as `riffer.tag.<key>` on **every** span the call emits (`invoke_agent`, `chat`, `execute_tool`, `execute_guardrail`). See [Tracing](TRACING.md).
381
402
 
403
+ ### Default tags
404
+
405
+ Every call also carries two tags riffer adds itself, so a provider can tell who the call is on behalf of:
406
+
407
+ | Tag | Value |
408
+ | ------- | ----------------------------------------------------------------------- |
409
+ | `kind` | `"agent"` (`"judge"` for [evaluator](EVALS.md) judge calls) |
410
+ | `agent` | The agent's `identifier` (the evaluator's `identifier` for judge calls) |
411
+
412
+ A tag you pass with the same key wins. The default tags count towards the provider limits below.
413
+
382
414
  ### Reserved key: `user_id`
383
415
 
384
416
  `user_id` is a reserved tag. Beyond appearing like any other tag, it maps to the provider's native end-user identifier where one exists (see the table).
@@ -393,6 +425,8 @@ Tags propagate to **two** places:
393
425
  | Anthropic | `metadata.user_id` **only** | The only tag forwarded |
394
426
  | Gemini | _(none — observability only)_ | Tag only; no request field |
395
427
 
428
+ If `model_options` already sets the native field (`metadata` or `request_metadata`), the tags are merged into it; a tag wins on a shared key.
429
+
396
430
  **Anthropic silently drops non-`user_id` tags.** The Messages API has no free-form request-metadata field — only `metadata.user_id`. So for Anthropic, `user_id` is forwarded as `metadata: {user_id: …}` and **every other tag is dropped from the request** (it still appears on spans). This is intentional.
397
431
 
398
432
  **Gemini is observability-only.** Riffer's Gemini adapter targets the Gemini Developer API (`generativelanguage.googleapis.com`), whose `generateContent` request has **no** `labels` field — sending unknown fields is rejected. So tags are **not** added to the Gemini request; they propagate to spans only. Native request labels (`labels`, lowercase `[a-z0-9_-]`, ≤63 chars each) are a Vertex AI feature and would arrive with a future Vertex adapter.
@@ -315,6 +315,7 @@ agent.context[:skills] # the Skills::Context, if skills configured
315
315
  | `content` | `String` | The response text |
316
316
  | `outcome` | `Outcome` | How the run ended — `reason` and optional `detail` (see below) |
317
317
  | `structured_output` | `Hash` / `nil` | Parsed and validated structured output (see below) |
318
+ | `reasoning` | `Array[ReasoningPart]` | The [reasoning parts](MESSAGES.md#reasoning) on the final assistant message (else `[]`) |
318
319
  | `tripwire` | `Tripwire` / `nil` | The guardrail tripwire that blocked the request |
319
320
  | `modified?` | `Boolean` | `true` if a guardrail modified the content |
320
321
  | `modifications` | `Array` | List of guardrail modifications applied |
@@ -184,7 +184,7 @@ end
184
184
 
185
185
  ### File Downloads
186
186
 
187
- File-attachment-download policy lives under `config.files`. Before an LLM call, riffer resolves every `Riffer::Messages::FilePart` attached to a user message against the provider's own capability — some providers accept a URL as-is, some need the bytes inline, and some can't take an attachment at all. See [Messages — File Parts](MESSAGES.md#file-parts) for `FilePart` itself and its `sha256:` field.
187
+ File-attachment-download policy lives under `config.files`. Before an LLM call, riffer resolves every `Riffer::Messages::User::FilePart` attached to a user message against the provider's own capability — some providers accept a URL as-is, some need the bytes inline, and some can't take an attachment at all. See [Messages — File Parts](MESSAGES.md#file-parts) for `FilePart` itself and its `sha256:` field.
188
188
 
189
189
  ```ruby
190
190
  Riffer.configure do |config|
data/docs/EVALS.md CHANGED
@@ -194,11 +194,12 @@ Class methods:
194
194
  - `instructions(value)` - Evaluation criteria and scoring rubric (enables default `evaluate`)
195
195
  - `higher_is_better(value)` - Whether higher scores are better (default: true)
196
196
  - `judge_model(value)` - Override the global judge model
197
+ - `identifier(value)` - Override the identifier sent with judge calls (default: the snake_cased class name, or `riffer/judge` for an anonymous class)
197
198
 
198
199
  Instance methods:
199
200
 
200
201
  - `evaluate(input:, output:, ground_truth:, messages:)` - Override for custom logic; default calls judge with `instructions`
201
- - `judge` - Returns a Judge instance for LLM-as-judge calls
202
+ - `judge` - Returns a Judge instance for LLM-as-judge calls. Its calls carry the [default tags](AGENTS.md#default-tags) `kind: "judge"` and `agent: <identifier>`
202
203
  - `result(score:, reason:, metadata:, token_usage:)` - Helper to build Result objects
203
204
 
204
205
  ### Advanced: Custom Evaluate Override
data/docs/MCP.md CHANGED
@@ -89,10 +89,6 @@ MCP tools are appended after any tools declared with `uses_tools`.
89
89
 
90
90
  Tool names must be unique across `uses_tools` and all included MCP servers; duplicate names raise `Riffer::ArgumentError` when tools are resolved.
91
91
 
92
- ### Subclassing
93
-
94
- Like [`uses_tools`](AGENTS.md#uses_tools), **`use_mcp` is not inherited** from the superclass. Declare `use_mcp` on each agent class that should load MCP tools.
95
-
96
92
  ## Progressive Tool Discovery
97
93
 
98
94
  Progressive discovery is the default. The `use_mcp` instruction exposes **`mcp_search`** instead of flooding the context with every tool schema up front.
data/docs/MESSAGES.md CHANGED
@@ -32,9 +32,9 @@ msg.to_h # => {role: :user, content: "Hello, how are you?"}
32
32
  User messages can include file attachments:
33
33
 
34
34
  ```ruby
35
- file = Riffer::Messages::FilePart.from_path("photo.jpg")
35
+ file = Riffer::Messages::User::FilePart.from_path("photo.jpg")
36
36
  msg = Riffer::Messages::User.new("Describe this image", files: [file])
37
- msg.files # => [#<Riffer::Messages::FilePart ...>]
37
+ msg.files # => [#<Riffer::Messages::User::FilePart ...>]
38
38
  msg.to_h # => {role: :user, content: "Describe this image", files: [{...}]}
39
39
  ```
40
40
 
@@ -67,6 +67,19 @@ if msg.token_usage
67
67
  end
68
68
  ```
69
69
 
70
+ #### Tool Calls
71
+
72
+ `Riffer::Messages::Assistant::ToolCall` is the normalized container riffer stores a requested tool invocation in. Each call carries `call_id` (the provider's identifier, passed back as the tool result's `tool_call_id`), `name`, and `arguments` (the JSON-encoded argument string exactly as the provider emitted it); `to_h` serializes all three.
73
+
74
+ ```ruby
75
+ tool_call = Riffer::Messages::Assistant::ToolCall.new(call_id: "call_123", name: "weather_tool", arguments: '{"city":"Tokyo"}')
76
+ msg = Riffer::Messages::Assistant.new("", tool_calls: [tool_call])
77
+
78
+ msg.has_tool_calls? # => true
79
+ msg.tool_calls.first.name # => "weather_tool"
80
+ tool_call.to_h # => {call_id: "call_123", name: "weather_tool", arguments: '{"city":"Tokyo"}'}
81
+ ```
82
+
70
83
  #### Token Usage Semantics
71
84
 
72
85
  `TokenUsage` buckets carry the same meaning for every provider, regardless of how the provider reports its raw usage:
@@ -84,16 +97,16 @@ The cache buckets are subsets of `input_tokens`, never additions to it — summi
84
97
 
85
98
  `finish_reason` carries the same meaning for every provider — each adapter maps its raw wire value (Anthropic's `end_turn`, OpenAI's response status, Gemini's `STOP`, …) into a normalized vocabulary:
86
99
 
87
- | Value | Meaning |
88
- | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
89
- | `:stop` | The model finished its turn naturally (or hit a stop sequence). |
90
- | `:length` | Output was truncated at the max-token limit. |
91
- | `:tool_calls` | The model stopped to call tools. |
92
- | `:content_filter` | A provider safety system blocked or cut the response. |
93
- | `:context_window` | Input plus output hit the model's context window; trim or compact history rather than raising `max_tokens`. |
100
+ | Value | Meaning |
101
+ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
102
+ | `:stop` | The model finished its turn naturally (or hit a stop sequence). |
103
+ | `:length` | Output was truncated at the max-token limit. |
104
+ | `:tool_calls` | The model stopped to call tools. |
105
+ | `:content_filter` | A provider safety system blocked or cut the response. |
106
+ | `:context_window` | Input plus output hit the model's context window; trim or compact history rather than raising `max_tokens`. |
94
107
  | `:malformed_output` | The model emitted output the provider could not parse, such as an invalid tool call; retry or nudge rather than backing off. |
95
- | `:error` | The provider reported an error finish. |
96
- | `:other` | A provider-specific value with no normalized equivalent. |
108
+ | `:error` | The provider reported an error finish. |
109
+ | `:other` | A provider-specific value with no normalized equivalent. |
97
110
 
98
111
  `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:
99
112
 
@@ -124,6 +137,61 @@ msg = Riffer::Messages::Assistant.new('{"sentiment":"positive"}', structured_out
124
137
  msg.to_h # => {role: :assistant, content: '{"sentiment":"positive"}', structured_output: {sentiment: "positive"}}
125
138
  ```
126
139
 
140
+ #### Reasoning
141
+
142
+ Reasoning models emit thinking blocks alongside their answer, and several providers require those blocks back on the next turn of a tool-calling loop. `Riffer::Messages::Assistant::ReasoningPart` is the normalized container riffer stores them in: a list of parts on the assistant message, in the order the provider emitted them.
143
+
144
+ ```ruby
145
+ summary = Riffer::Messages::Assistant::ReasoningPart.new(type: :summary, text: "The user wants the answer.", format: "mock-v1")
146
+ opaque = Riffer::Messages::Assistant::ReasoningPart.new(type: :encrypted, data: "b3BhcXVl", format: "mock-v1")
147
+ msg = Riffer::Messages::Assistant.new("42", reasoning: [summary, opaque])
148
+
149
+ msg.reasoning? # => true
150
+ msg.reasoning_text # => "The user wants the answer."
151
+ msg.reasoning.first.type # => :summary
152
+ ```
153
+
154
+ A readable part next to an opaque one is the common shape, not a contrived one: OpenAI's Responses API returns a reasoning item as summary text plus an encrypted payload, and Anthropic pairs a `thinking` block with a `redacted_thinking` block when it redacts part of the chain of thought.
155
+
156
+ `reasoning:` takes `ReasoningPart`s; `Riffer::Messages::Base.from_hash` is what turns persisted hashes back into parts.
157
+
158
+ Each part carries:
159
+
160
+ | Field | Type | Description |
161
+ | ----------- | --------- | --------------------------------------------------------------------------------------------------------------- |
162
+ | `type` | `Symbol` | One of `:text` (readable reasoning), `:summary` (a provider-condensed digest), `:encrypted` (an opaque payload) |
163
+ | `text` | `String?` | The reasoning prose, for `:text` and `:summary` parts |
164
+ | `data` | `String?` | The opaque payload, for `:encrypted` parts |
165
+ | `signature` | `String?` | The provider's signature over the part, when it issues one |
166
+ | `id` | `String?` | The provider's identifier for the part, when it issues one |
167
+ | `format` | `String?` | The wire format, owned by the adapter that produced the part (e.g. `"anthropic-claude-v1"`) |
168
+
169
+ A `type` outside the three values raises `Riffer::ArgumentError`. `format` is a free string riffer never validates — it exists so an adapter can tell its own parts apart from another adapter's.
170
+
171
+ `reasoning?` is true when the message carries any part. `reasoning_text` joins the `text` of the `:text` and `:summary` parts with blank lines, skipping `:encrypted` parts, and is `nil` when there is nothing to join. The run's final assistant message projects its parts onto `response.reasoning` (see [Agent Lifecycle — Response Attributes](AGENT_LIFECYCLE.md#response-attributes)).
172
+
173
+ Parts round-trip through `to_h` / `from_hash` like every other message field, so an application that persists sessions can store and replay them. The `reasoning` key is absent from `to_h` when the message has no parts, and each part omits the fields it doesn't carry:
174
+
175
+ ```ruby
176
+ msg.to_h
177
+ # => {role: :assistant, content: "42", reasoning: [
178
+ # {type: :summary, text: "The user wants the answer.", format: "mock-v1"},
179
+ # {type: :encrypted, data: "b3BhcXVl", format: "mock-v1"}
180
+ # ]}
181
+
182
+ Riffer::Messages::Base.from_hash(msg.to_h).reasoning # => [ReasoningPart, ReasoningPart]
183
+ ```
184
+
185
+ **The replay contract.** A provider adapter replays only the parts whose `format` it recognizes and silently skips the rest, so history that travelled through another provider is never rejected. A part with no `format` is never replayed; adapters that surface reasoning text but cannot yet send it back emit their parts that way, so the text is kept for display without risking a rejected request. Parts are never reordered, merged, or edited — riffer treats them as opaque, because the provider's signature covers their exact bytes.
186
+
187
+ An application that would rather not store parts can leave the key out when it serializes:
188
+
189
+ ```ruby
190
+ msg.to_h.except(:reasoning)
191
+ ```
192
+
193
+ To drop parts from the in-memory session mid-run instead, `Session#update(id:, reasoning: [])` rewrites the message in place; it needs [message ids](#ids) enabled to address it.
194
+
127
195
  ### Tool
128
196
 
129
197
  Tool messages contain the results of tool executions:
@@ -155,7 +223,7 @@ msg.error_type # => :execution_error
155
223
 
156
224
  ## File Parts
157
225
 
158
- `Riffer::Messages::FilePart` represents a file attachment (image or document) that can be included with user messages.
226
+ `Riffer::Messages::User::FilePart` represents a file attachment (image or document) that can be included with user messages.
159
227
 
160
228
  ### Supported Media Types
161
229
 
@@ -167,21 +235,21 @@ msg.error_type # => :execution_error
167
235
 
168
236
  ```ruby
169
237
  # From a file path (reads eagerly, detects media type from extension)
170
- file = Riffer::Messages::FilePart.from_path("photo.jpg")
238
+ file = Riffer::Messages::User::FilePart.from_path("photo.jpg")
171
239
  file.media_type # => "image/jpeg"
172
240
  file.filename # => "photo.jpg"
173
241
  file.image? # => true
174
242
 
175
243
  # From a URL (stored directly, resolved lazily if provider needs bytes)
176
- file = Riffer::Messages::FilePart.from_url("https://example.com/doc.pdf")
244
+ file = Riffer::Messages::User::FilePart.from_url("https://example.com/doc.pdf")
177
245
  file.url? # => true
178
246
  file.document? # => true
179
247
 
180
248
  # From raw base64 data
181
- file = Riffer::Messages::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")
249
+ file = Riffer::Messages::User::FilePart.new(media_type: "image/png", data: base64_string, filename: "chart.png")
182
250
 
183
251
  # With an expected sha256 checksum of the file's contents
184
- file = Riffer::Messages::FilePart.from_url(
252
+ file = Riffer::Messages::User::FilePart.from_url(
185
253
  "https://example.com/doc.pdf",
186
254
  sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
187
255
  )
@@ -243,7 +311,7 @@ agent = MyAgent.new(session: session)
243
311
  response = agent.generate # session already carries the last user turn
244
312
  ```
245
313
 
246
- `Riffer::Agent::Session.new(messages:)` accepts `Riffer::Messages::Base` objects. If your persistence layer hands back hashes, normalize them first via `Riffer::Messages::Base.from_hash` or your own adapter.
314
+ `Riffer::Agent::Session.new(messages:)` accepts `Riffer::Messages::Base` objects. If your persistence layer hands back hashes, normalize them first via `Riffer::Messages::Base.from_hash` (which dispatches on `:role`), a role's own `from_hash` such as `Riffer::Messages::User.from_hash` when the role is already known, or your own adapter.
247
315
 
248
316
  ### Accessing Message History
249
317
 
@@ -105,14 +105,18 @@ event.content # => "Let me think about "
105
105
 
106
106
  ### ReasoningDone
107
107
 
108
- Emitted when reasoning is complete:
108
+ Emitted when one reasoning block is complete:
109
109
 
110
110
  ```ruby
111
- event = Riffer::StreamEvents::ReasoningDone.new("Let me think about this step by step...")
112
- event.role # => :assistant
113
- event.content # => "Let me think about this step by step..."
111
+ part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: "Let me think about this step by step...", format: "mock-v1")
112
+ event = Riffer::StreamEvents::ReasoningDone.new(part)
113
+ event.role # => :assistant
114
+ event.part # => the ReasoningPart
115
+ event.part.text # => "Let me think about this step by step..."
114
116
  ```
115
117
 
118
+ `part` is the [reasoning part](MESSAGES.md#reasoning) the preceding `ReasoningDelta` events added up to, and the agent loop accumulates it onto the assistant message. Adapters that cannot yet replay their reasoning emit it as a `:text` part with no `format`, so it is stored for display but never sent back to the provider.
119
+
116
120
  ### WebSearchStatus
117
121
 
118
122
  Emitted during web search progress with status updates:
@@ -272,6 +276,29 @@ event.to_h # => {role: :assistant, finish_reason: :length, raw_fin
272
276
 
273
277
  The agent loop stamps this value onto the accumulated assistant message's `finish_reason`.
274
278
 
279
+ ## Incomplete Streams
280
+
281
+ If a provider's stream ends before its terminal event, the enumerator raises `Riffer::IncompleteStreamError` (a `Riffer::Error` subclass) instead of finishing normally, so a truncated or empty response is never returned as a complete message. Events already yielded before the raise were delivered as usual, but nothing from the failed step is added to the session: there is no partial assistant message to resume from, and messages from earlier completed steps (tool calls and their results) are untouched.
282
+
283
+ Supported on Amazon Bedrock, Anthropic, and OpenAI / Azure OpenAI.
284
+
285
+ The user prompt is added to the session before the run starts and stays there after the failure, so retry with `agent.stream` and no prompt. Passing the prompt again would add a second user turn.
286
+
287
+ ```ruby
288
+ attempts = 0
289
+ prompt = "Tell me a story"
290
+ begin
291
+ agent.stream(prompt).each do |event|
292
+ print event.content if event.is_a?(Riffer::StreamEvents::TextDelta)
293
+ end
294
+ rescue Riffer::IncompleteStreamError => e
295
+ warn "stream ended early: #{e.message}"
296
+ prompt = nil # already in the session; re-run on the existing history
297
+ retry if (attempts += 1) < 3
298
+ raise
299
+ end
300
+ ```
301
+
275
302
  ## Streaming with Tools
276
303
 
277
304
  When an agent uses tools during streaming, the flow is:
data/docs/TRACING.md CHANGED
@@ -95,7 +95,7 @@ The contract promise is: **when present**, a key carries the documented meaning
95
95
 
96
96
  ### Per-call tags (`riffer.tag.*`)
97
97
 
98
- Any tags passed to `#generate` / `#stream` via `tags:` are stamped on **all four** span types as `riffer.tag.<key>` (string), so the per-span tables below omit them. They appear on every span the tagged call emits and are absent otherwise. Example: `tags: {team: "growth"}` adds `riffer.tag.team` → `"growth"` to the `invoke_agent`, `chat`, `execute_tool`, and `execute_guardrail` spans. See [Per-Call Tags](AGENTS.md#per-call-tags) for the full surface and the per-provider request-metadata mapping.
98
+ Any tags passed to `#generate` / `#stream` via `tags:` are stamped on **all four** span types as `riffer.tag.<key>` (string), so the per-span tables below omit them. They appear on every span the tagged call emits and are absent otherwise. Example: `tags: {team: "growth"}` adds `riffer.tag.team` → `"growth"` to the `invoke_agent`, `chat`, `execute_tool`, and `execute_guardrail` spans. The [default tags](AGENTS.md#default-tags) are always present, e.g. `riffer.tag.kind` → `"agent"` and `riffer.tag.agent` → the agent identifier. See [Per-Call Tags](AGENTS.md#per-call-tags) for the full surface and the per-provider request-metadata mapping.
99
99
 
100
100
  ## `invoke_agent {agent}` — the run span
101
101
 
@@ -171,7 +171,7 @@ end
171
171
  Bedrock accepts file attachments either as raw bytes, or as `s3://` URIs passed straight through to Converse — Bedrock fetches the S3 object itself:
172
172
 
173
173
  ```ruby
174
- file = Riffer::Messages::FilePart.from_url("s3://my-bucket/document.pdf", media_type: "application/pdf")
174
+ file = Riffer::Messages::User::FilePart.from_url("s3://my-bucket/document.pdf", media_type: "application/pdf")
175
175
  response = provider.generate_text(
176
176
  prompt: "Summarize this document",
177
177
  model: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
@@ -24,7 +24,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
24
24
  params = {
25
25
  model: model,
26
26
  messages: convert_messages(messages),
27
- **options.except(:tools)
27
+ **options.except(:tools, :tags)
28
28
  }
29
29
 
30
30
  if tools && !tools.empty?
@@ -220,9 +220,9 @@ Riffer::StreamEvents::ToolCallDone.new(
220
220
  arguments: '{"complete":"args"}'
221
221
  )
222
222
 
223
- # Reasoning (if supported)
223
+ # Reasoning (if supported); see the Reasoning section for building the part
224
224
  Riffer::StreamEvents::ReasoningDelta.new("thinking...")
225
- Riffer::StreamEvents::ReasoningDone.new("complete reasoning")
225
+ Riffer::StreamEvents::ReasoningDone.new(part)
226
226
 
227
227
  # Web search (if supported)
228
228
  Riffer::StreamEvents::WebSearchStatus.new("searching", query: "search query")
@@ -274,6 +274,60 @@ For streaming, emit a `FinishReasonDone` event near the end of `execute_stream`:
274
274
  yielder << Riffer::StreamEvents::FinishReasonDone.new(finish_reason: :stop, raw_finish_reason: "done")
275
275
  ```
276
276
 
277
+ Also have `execute_stream` raise `Riffer::IncompleteStreamError` when the stream ends without the provider's terminal event, rather than returning normally. Otherwise a connection that drops mid-response looks identical to a finished one, and the agent loop accepts a truncated message as complete.
278
+
279
+ ## Reasoning
280
+
281
+ `extract_reasoning` is the optional hook for reasoning models — return the response's thinking blocks as [`Riffer::Messages::Assistant::ReasoningPart`s](../MESSAGES.md#reasoning) and the base class attaches them to the assistant message, where your application can persist them and hand them back on the next turn:
282
+
283
+ ```ruby
284
+ def extract_reasoning(response)
285
+ response.thinking_blocks.map do |block|
286
+ Riffer::Messages::Assistant::ReasoningPart.new(
287
+ type: :encrypted,
288
+ data: block.data,
289
+ signature: block.signature,
290
+ format: "my-provider-v1"
291
+ )
292
+ end
293
+ end
294
+ ```
295
+
296
+ The base class defaults to `[]`, so a provider without reasoning stays valid.
297
+
298
+ Your adapter owns its `format` string: pick one value per wire shape, replay only the parts carrying a value you recognize, and skip the rest — history that travelled through another provider must never make a request fail. Never reorder or edit a part; the provider's signature covers its exact bytes.
299
+
300
+ For streaming, emit one `ReasoningDone` per block, carrying the part so the agent loop can accumulate it:
301
+
302
+ ```ruby
303
+ part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: "complete reasoning", format: "my-provider-v1")
304
+
305
+ yielder << Riffer::StreamEvents::ReasoningDelta.new("thinking...")
306
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
307
+ ```
308
+
309
+ If your adapter surfaces reasoning text but cannot yet replay it, call `yield_reasoning_done(yielder, text)` instead. It wraps the text in a `:text` part with no `format`, which persists for display and is skipped on replay.
310
+
311
+ ## Tags
312
+
313
+ Every agent and judge call passes a `:tags` option: a flat `String => String` hash of the caller's [per-call tags](../AGENTS.md#per-call-tags) plus the [default tags](../AGENTS.md#default-tags). `kind` (`"agent"` or `"judge"`) and `agent` (the agent or evaluator identifier) tell you who the call is on behalf of:
314
+
315
+ ```ruby
316
+ def build_request_params(messages, model, options)
317
+ tags = options[:tags] || {}
318
+
319
+ {
320
+ agent: tags["agent"],
321
+ user: tags["user_id"],
322
+ messages: convert_messages(messages),
323
+ model: model,
324
+ **options.except(:tools, :tags),
325
+ }
326
+ end
327
+ ```
328
+
329
+ Map tags to your service's native request field, or drop them, but don't pass `:tags` on to an SDK verbatim.
330
+
277
331
  ## Trace Provider Name
278
332
 
279
333
  LLM-call and agent-run spans stamp `gen_ai.provider.name` from the `semconv_provider_name` class method. The default is your snake_cased class name; override it when a [GenAI semconv well-known value](https://opentelemetry.io/docs/specs/semconv/gen-ai/) exists for your provider:
@@ -328,7 +382,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
328
382
  messages: convert_messages(conversation),
329
383
  system: system_message,
330
384
  max_tokens: options[:max_tokens] || 4096,
331
- **options.except(:tools, :max_tokens)
385
+ **options.except(:tools, :max_tokens, :tags)
332
386
  }
333
387
 
334
388
  if tools && !tools.empty?
@@ -148,7 +148,7 @@ response = provider.generate_text(
148
148
  Gemini's API only accepts inline base64-encoded files (images and documents), never a URL reference:
149
149
 
150
150
  ```ruby
151
- file = Riffer::Messages::FilePart.new(data: base64_data, media_type: "image/png")
151
+ file = Riffer::Messages::User::FilePart.new(data: base64_data, media_type: "image/png")
152
152
  response = provider.generate_text(
153
153
  prompt: "Describe this image",
154
154
  model: "gemini-2.5-flash-lite",
@@ -47,6 +47,23 @@ provider.stub_response("Based on the tool result, here's my answer.")
47
47
  response = agent.generate("Use the tool")
48
48
  ```
49
49
 
50
+ ## Stubbing Reasoning
51
+
52
+ Stub [reasoning parts](../MESSAGES.md#reasoning) to exercise your application's persistence of them. Hashes are normalized into `Riffer::Messages::Assistant::ReasoningPart`s:
53
+
54
+ ```ruby
55
+ provider.stub_response("42", reasoning: [
56
+ {type: :text, text: "The user wants the answer.", format: "mock-v1"},
57
+ {type: :encrypted, data: "b3BhcXVl", signature: "sig", format: "mock-v1"}
58
+ ])
59
+
60
+ response = agent.generate("What is the answer?")
61
+ response.reasoning.map(&:type) # => [:text, :encrypted]
62
+ agent.session.messages.last.reasoning_text # => "The user wants the answer."
63
+ ```
64
+
65
+ When streaming, each part is emitted as a `ReasoningDelta` (only when it carries `text`) followed by a `ReasoningDone` carrying the part, ahead of the text events.
66
+
50
67
  ## Queueing Multiple Responses
51
68
 
52
69
  Responses are consumed in order: