riffer 0.47.2 → 0.49.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 (267) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/rules/comments.md +2 -4
  3. data/.claude/rules/rbs-inline.md +2 -12
  4. data/.release-please-manifest.json +1 -1
  5. data/.rubocop.yml +5 -0
  6. data/CHANGELOG.md +42 -0
  7. data/docs/AGENTS.md +39 -7
  8. data/docs/AGENT_LIFECYCLE.md +14 -16
  9. data/docs/CONFIGURATION.md +23 -34
  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 +8 -4
  14. data/docs/TOOL_ADVANCED.md +1 -3
  15. data/docs/TRACING.md +2 -2
  16. data/docs/providers/AMAZON_BEDROCK.md +32 -1
  17. data/docs/providers/CUSTOM_PROVIDERS.md +56 -4
  18. data/docs/providers/GEMINI.md +1 -1
  19. data/docs/providers/MOCK_PROVIDER.md +17 -0
  20. data/docs/providers/OPENROUTER.md +18 -1
  21. data/docs-site/build.rb +0 -6
  22. data/docs-site/check.rb +1 -5
  23. data/lib/riffer/agent/config.rb +27 -44
  24. data/lib/riffer/agent/context.rb +2 -26
  25. data/lib/riffer/agent/outcome.rb +0 -20
  26. data/lib/riffer/agent/response.rb +4 -35
  27. data/lib/riffer/agent/run.rb +32 -48
  28. data/lib/riffer/agent/serializer.rb +10 -39
  29. data/lib/riffer/agent/session/repair.rb +3 -15
  30. data/lib/riffer/agent/session.rb +26 -38
  31. data/lib/riffer/agent/structured_output/result.rb +0 -8
  32. data/lib/riffer/agent/structured_output.rb +0 -7
  33. data/lib/riffer/agent.rb +31 -127
  34. data/lib/riffer/config/amazon_bedrock.rb +30 -0
  35. data/lib/riffer/config/anthropic.rb +21 -0
  36. data/lib/riffer/config/azure_open_ai.rb +30 -0
  37. data/lib/riffer/config/evals.rb +18 -0
  38. data/lib/riffer/config/files.rb +61 -0
  39. data/lib/riffer/config/gemini.rb +21 -0
  40. data/lib/riffer/config/mcp.rb +31 -0
  41. data/lib/riffer/config/open_ai.rb +30 -0
  42. data/lib/riffer/config/open_router.rb +21 -0
  43. data/lib/riffer/config/pricing/rates.rb +36 -0
  44. data/lib/riffer/config/pricing.rb +64 -0
  45. data/lib/riffer/config/skills.rb +38 -0
  46. data/lib/riffer/config/tracing.rb +43 -0
  47. data/lib/riffer/config.rb +3 -362
  48. data/lib/riffer/evals/evaluator.rb +22 -23
  49. data/lib/riffer/evals/evaluator_runner.rb +0 -15
  50. data/lib/riffer/evals/judge.rb +9 -11
  51. data/lib/riffer/evals/result.rb +1 -11
  52. data/lib/riffer/evals/run_result.rb +0 -12
  53. data/lib/riffer/evals/scenario_result.rb +0 -19
  54. data/lib/riffer/files/downloader.rb +2 -3
  55. data/lib/riffer/files/resolver.rb +5 -10
  56. data/lib/riffer/guardrail.rb +2 -22
  57. data/lib/riffer/guardrails/modification.rb +0 -8
  58. data/lib/riffer/guardrails/result.rb +0 -17
  59. data/lib/riffer/guardrails/runner.rb +2 -11
  60. data/lib/riffer/guardrails/tripwire.rb +0 -8
  61. data/lib/riffer/guardrails.rb +0 -2
  62. data/lib/riffer/helpers/boolean.rb +0 -4
  63. data/lib/riffer/helpers/call_or_value.rb +0 -3
  64. data/lib/riffer/helpers/deep_dup.rb +37 -0
  65. data/lib/riffer/helpers/dependencies.rb +0 -4
  66. data/lib/riffer/helpers/identifier.rb +3 -12
  67. data/lib/riffer/helpers/validate.rb +42 -0
  68. data/lib/riffer/mcp/authenticated_tool.rb +4 -12
  69. data/lib/riffer/mcp/client.rb +0 -7
  70. data/lib/riffer/mcp/manifest.rb +3 -7
  71. data/lib/riffer/mcp/registration.rb +0 -10
  72. data/lib/riffer/mcp/registry.rb +0 -9
  73. data/lib/riffer/mcp/search_tool.rb +0 -4
  74. data/lib/riffer/mcp/tool.rb +1 -4
  75. data/lib/riffer/mcp/tool_factory.rb +3 -6
  76. data/lib/riffer/mcp.rb +2 -23
  77. data/lib/riffer/messages/assistant/reasoning_part.rb +73 -0
  78. data/lib/riffer/messages/assistant/tool_call.rb +55 -0
  79. data/lib/riffer/messages/assistant.rb +43 -15
  80. data/lib/riffer/messages/base.rb +5 -33
  81. data/lib/riffer/messages/system.rb +8 -1
  82. data/lib/riffer/messages/tool.rb +15 -13
  83. data/lib/riffer/messages/{file_part.rb → user/file_part.rb} +16 -40
  84. data/lib/riffer/messages/user.rb +11 -4
  85. data/lib/riffer/params/boolean.rb +1 -5
  86. data/lib/riffer/params/param.rb +15 -31
  87. data/lib/riffer/params.rb +15 -39
  88. data/lib/riffer/providers/amazon_bedrock.rb +104 -44
  89. data/lib/riffer/providers/anthropic.rb +11 -26
  90. data/lib/riffer/providers/azure_open_ai.rb +3 -8
  91. data/lib/riffer/providers/base.rb +30 -32
  92. data/lib/riffer/providers/finish_reason.rb +0 -6
  93. data/lib/riffer/providers/gemini/client.rb +4 -17
  94. data/lib/riffer/providers/gemini.rb +6 -12
  95. data/lib/riffer/providers/mock.rb +18 -27
  96. data/lib/riffer/providers/open_ai.rb +12 -21
  97. data/lib/riffer/providers/open_router.rb +109 -33
  98. data/lib/riffer/providers/repository.rb +2 -14
  99. data/lib/riffer/providers/token_usage.rb +19 -12
  100. data/lib/riffer/registrable.rb +11 -45
  101. data/lib/riffer/runner/fibers.rb +1 -6
  102. data/lib/riffer/runner/sequential.rb +0 -1
  103. data/lib/riffer/runner/threaded.rb +0 -3
  104. data/lib/riffer/runner.rb +0 -3
  105. data/lib/riffer/skills/activate_tool.rb +0 -3
  106. data/lib/riffer/skills/adapter.rb +0 -8
  107. data/lib/riffer/skills/backend.rb +2 -7
  108. data/lib/riffer/skills/config.rb +13 -17
  109. data/lib/riffer/skills/context.rb +0 -29
  110. data/lib/riffer/skills/filesystem_backend.rb +0 -7
  111. data/lib/riffer/skills/frontmatter.rb +2 -16
  112. data/lib/riffer/skills/markdown_adapter.rb +3 -6
  113. data/lib/riffer/skills/xml_adapter.rb +0 -3
  114. data/lib/riffer/stream_events/base.rb +0 -3
  115. data/lib/riffer/stream_events/finish_reason_done.rb +1 -6
  116. data/lib/riffer/stream_events/guardrail_modification.rb +0 -10
  117. data/lib/riffer/stream_events/guardrail_tripwire.rb +0 -10
  118. data/lib/riffer/stream_events/interrupt.rb +2 -12
  119. data/lib/riffer/stream_events/reasoning_delta.rb +0 -3
  120. data/lib/riffer/stream_events/reasoning_done.rb +6 -8
  121. data/lib/riffer/stream_events/skill_activation.rb +0 -3
  122. data/lib/riffer/stream_events/text_delta.rb +0 -2
  123. data/lib/riffer/stream_events/text_done.rb +0 -2
  124. data/lib/riffer/stream_events/token_usage_done.rb +0 -2
  125. data/lib/riffer/stream_events/tool_call_delta.rb +1 -5
  126. data/lib/riffer/stream_events/tool_call_done.rb +0 -5
  127. data/lib/riffer/stream_events/web_search_done.rb +0 -3
  128. data/lib/riffer/stream_events/web_search_status.rb +1 -5
  129. data/lib/riffer/testing/minitest.rb +4 -5
  130. data/lib/riffer/testing.rb +5 -38
  131. data/lib/riffer/tool.rb +2 -28
  132. data/lib/riffer/tools/response.rb +3 -32
  133. data/lib/riffer/tools/runtime/fibers.rb +0 -6
  134. data/lib/riffer/tools/runtime/inline.rb +0 -1
  135. data/lib/riffer/tools/runtime/threaded.rb +0 -6
  136. data/lib/riffer/tools/runtime.rb +8 -30
  137. data/lib/riffer/tools/toolable.rb +0 -37
  138. data/lib/riffer/tracing/capture.rb +5 -9
  139. data/lib/riffer/tracing/no_op.rb +0 -7
  140. data/lib/riffer/tracing/otel.rb +7 -16
  141. data/lib/riffer/tracing/stream_recorder.rb +0 -7
  142. data/lib/riffer/tracing.rb +4 -27
  143. data/lib/riffer/version.rb +1 -1
  144. data/lib/riffer.rb +2 -30
  145. data/sig/generated/riffer/agent/config.rbs +24 -46
  146. data/sig/generated/riffer/agent/context.rbs +2 -26
  147. data/sig/generated/riffer/agent/outcome.rbs +0 -20
  148. data/sig/generated/riffer/agent/response.rbs +4 -27
  149. data/sig/generated/riffer/agent/run.rbs +12 -31
  150. data/sig/generated/riffer/agent/serializer.rbs +2 -27
  151. data/sig/generated/riffer/agent/session/repair.rbs +2 -10
  152. data/sig/generated/riffer/agent/session.rbs +10 -36
  153. data/sig/generated/riffer/agent/structured_output/result.rbs +0 -7
  154. data/sig/generated/riffer/agent/structured_output.rbs +0 -7
  155. data/sig/generated/riffer/agent.rbs +25 -125
  156. data/sig/generated/riffer/config/amazon_bedrock.rbs +21 -0
  157. data/sig/generated/riffer/config/anthropic.rbs +15 -0
  158. data/sig/generated/riffer/config/azure_open_ai.rbs +21 -0
  159. data/sig/generated/riffer/config/evals.rbs +13 -0
  160. data/sig/generated/riffer/config/files.rbs +43 -0
  161. data/sig/generated/riffer/config/gemini.rbs +15 -0
  162. data/sig/generated/riffer/config/mcp.rbs +19 -0
  163. data/sig/generated/riffer/config/open_ai.rbs +21 -0
  164. data/sig/generated/riffer/config/open_router.rbs +15 -0
  165. data/sig/generated/riffer/config/pricing/rates.rbs +19 -0
  166. data/sig/generated/riffer/config/pricing.rbs +31 -0
  167. data/sig/generated/riffer/config/skills.rbs +19 -0
  168. data/sig/generated/riffer/config/tracing.rbs +25 -0
  169. data/sig/generated/riffer/config.rbs +2 -307
  170. data/sig/generated/riffer/evals/evaluator.rbs +12 -21
  171. data/sig/generated/riffer/evals/evaluator_runner.rbs +0 -14
  172. data/sig/generated/riffer/evals/judge.rbs +4 -8
  173. data/sig/generated/riffer/evals/result.rbs +1 -11
  174. data/sig/generated/riffer/evals/run_result.rbs +0 -12
  175. data/sig/generated/riffer/evals/scenario_result.rbs +0 -14
  176. data/sig/generated/riffer/files/resolver.rbs +6 -13
  177. data/sig/generated/riffer/guardrail.rbs +2 -22
  178. data/sig/generated/riffer/guardrails/modification.rbs +0 -6
  179. data/sig/generated/riffer/guardrails/result.rbs +0 -15
  180. data/sig/generated/riffer/guardrails/runner.rbs +0 -11
  181. data/sig/generated/riffer/guardrails/tripwire.rbs +0 -8
  182. data/sig/generated/riffer/guardrails.rbs +0 -2
  183. data/sig/generated/riffer/helpers/boolean.rbs +0 -4
  184. data/sig/generated/riffer/helpers/call_or_value.rbs +0 -3
  185. data/sig/generated/riffer/helpers/deep_dup.rbs +13 -0
  186. data/sig/generated/riffer/helpers/dependencies.rbs +0 -4
  187. data/sig/generated/riffer/helpers/identifier.rbs +0 -12
  188. data/sig/generated/riffer/helpers/validate.rbs +21 -0
  189. data/sig/generated/riffer/mcp/authenticated_tool.rbs +0 -5
  190. data/sig/generated/riffer/mcp/client.rbs +0 -7
  191. data/sig/generated/riffer/mcp/manifest.rbs +3 -7
  192. data/sig/generated/riffer/mcp/registration.rbs +0 -10
  193. data/sig/generated/riffer/mcp/registry.rbs +0 -9
  194. data/sig/generated/riffer/mcp/search_tool.rbs +0 -4
  195. data/sig/generated/riffer/mcp/tool.rbs +0 -4
  196. data/sig/generated/riffer/mcp/tool_factory.rbs +0 -6
  197. data/sig/generated/riffer/mcp.rbs +2 -22
  198. data/sig/generated/riffer/messages/assistant/reasoning_part.rbs +45 -0
  199. data/sig/generated/riffer/messages/assistant/tool_call.rbs +34 -0
  200. data/sig/generated/riffer/messages/assistant.rbs +25 -31
  201. data/sig/generated/riffer/messages/base.rbs +0 -12
  202. data/sig/generated/riffer/messages/system.rbs +4 -1
  203. data/sig/generated/riffer/messages/tool.rbs +4 -10
  204. data/sig/generated/riffer/messages/user/file_part.rbs +76 -0
  205. data/sig/generated/riffer/messages/user.rbs +7 -5
  206. data/sig/generated/riffer/params/boolean.rbs +1 -4
  207. data/sig/generated/riffer/params/param.rbs +7 -31
  208. data/sig/generated/riffer/params.rbs +8 -38
  209. data/sig/generated/riffer/providers/amazon_bedrock.rbs +33 -29
  210. data/sig/generated/riffer/providers/anthropic.rbs +2 -12
  211. data/sig/generated/riffer/providers/azure_open_ai.rbs +0 -8
  212. data/sig/generated/riffer/providers/base.rbs +18 -34
  213. data/sig/generated/riffer/providers/finish_reason.rbs +0 -6
  214. data/sig/generated/riffer/providers/gemini/client.rbs +4 -17
  215. data/sig/generated/riffer/providers/gemini.rbs +4 -10
  216. data/sig/generated/riffer/providers/mock.rbs +6 -27
  217. data/sig/generated/riffer/providers/open_ai.rbs +6 -13
  218. data/sig/generated/riffer/providers/open_router.rbs +37 -18
  219. data/sig/generated/riffer/providers/repository.rbs +2 -14
  220. data/sig/generated/riffer/providers/token_usage.rbs +9 -12
  221. data/sig/generated/riffer/registrable.rbs +0 -45
  222. data/sig/generated/riffer/runner/fibers.rbs +0 -6
  223. data/sig/generated/riffer/runner/sequential.rbs +0 -1
  224. data/sig/generated/riffer/runner/threaded.rbs +0 -3
  225. data/sig/generated/riffer/runner.rbs +0 -3
  226. data/sig/generated/riffer/skills/activate_tool.rbs +0 -3
  227. data/sig/generated/riffer/skills/adapter.rbs +0 -8
  228. data/sig/generated/riffer/skills/backend.rbs +2 -7
  229. data/sig/generated/riffer/skills/config.rbs +6 -17
  230. data/sig/generated/riffer/skills/context.rbs +0 -29
  231. data/sig/generated/riffer/skills/filesystem_backend.rbs +0 -7
  232. data/sig/generated/riffer/skills/frontmatter.rbs +2 -16
  233. data/sig/generated/riffer/skills/markdown_adapter.rbs +0 -6
  234. data/sig/generated/riffer/skills/xml_adapter.rbs +0 -3
  235. data/sig/generated/riffer/stream_events/base.rbs +0 -3
  236. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +1 -6
  237. data/sig/generated/riffer/stream_events/guardrail_modification.rbs +0 -10
  238. data/sig/generated/riffer/stream_events/guardrail_tripwire.rbs +0 -10
  239. data/sig/generated/riffer/stream_events/interrupt.rbs +2 -10
  240. data/sig/generated/riffer/stream_events/reasoning_delta.rbs +0 -3
  241. data/sig/generated/riffer/stream_events/reasoning_done.rbs +4 -6
  242. data/sig/generated/riffer/stream_events/skill_activation.rbs +0 -3
  243. data/sig/generated/riffer/stream_events/text_delta.rbs +0 -2
  244. data/sig/generated/riffer/stream_events/text_done.rbs +0 -2
  245. data/sig/generated/riffer/stream_events/token_usage_done.rbs +0 -2
  246. data/sig/generated/riffer/stream_events/tool_call_delta.rbs +1 -5
  247. data/sig/generated/riffer/stream_events/tool_call_done.rbs +0 -5
  248. data/sig/generated/riffer/stream_events/web_search_done.rbs +0 -3
  249. data/sig/generated/riffer/stream_events/web_search_status.rbs +1 -5
  250. data/sig/generated/riffer/testing.rbs +0 -38
  251. data/sig/generated/riffer/tool.rbs +0 -27
  252. data/sig/generated/riffer/tools/response.rbs +3 -29
  253. data/sig/generated/riffer/tools/runtime/fibers.rbs +0 -5
  254. data/sig/generated/riffer/tools/runtime/inline.rbs +0 -1
  255. data/sig/generated/riffer/tools/runtime/threaded.rbs +0 -5
  256. data/sig/generated/riffer/tools/runtime.rbs +4 -26
  257. data/sig/generated/riffer/tools/toolable.rbs +0 -37
  258. data/sig/generated/riffer/tracing/capture.rbs +6 -9
  259. data/sig/generated/riffer/tracing/no_op.rbs +0 -7
  260. data/sig/generated/riffer/tracing/otel.rbs +7 -16
  261. data/sig/generated/riffer/tracing/stream_recorder.rbs +0 -2
  262. data/sig/generated/riffer/tracing.rbs +4 -23
  263. data/sig/generated/riffer.rbs +2 -29
  264. data/sig/manual/riffer/helpers/deep_dup.rbs +5 -0
  265. data/sig/manual/riffer/helpers/validate.rbs +5 -0
  266. metadata +39 -3
  267. data/sig/generated/riffer/messages/file_part.rbs +0 -101
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:
@@ -112,9 +112,7 @@ For expected failures, return `error(...)` or raise `Riffer::ToolExecutionError`
112
112
 
113
113
  The LLM receives the error message and can decide how to respond (retry, apologize, ask for different input, etc.).
114
114
 
115
- ## Tool Runtime (Experimental)
116
-
117
- > **Warning:** This feature is experimental and may be removed or changed without warning in a future release.
115
+ ## Tool Runtime
118
116
 
119
117
  By default, tool calls are executed sequentially in the current thread using `Riffer::Tools::Runtime::Inline`. You can change how tool calls are executed by configuring a different tool runtime.
120
118
 
data/docs/TRACING.md CHANGED
@@ -58,7 +58,7 @@ Riffer.configure do |config|
58
58
  end
59
59
  ```
60
60
 
61
- The backend is duck-typed — any object satisfying the contract works, and the setter validates only that it responds to `in_span` (otherwise it raises `Riffer::ArgumentError`). It must respond to:
61
+ The backend is duck-typed — any object satisfying the contract works, and the setter validates that it responds to `in_span`, `current_context`, and `with_context` (otherwise it raises `Riffer::ArgumentError`). It must respond to:
62
62
 
63
63
  - `in_span(name, attributes:, kind:) { |span| … }` — open a span around the block, yield a span object, and return the block's value.
64
64
  - `current_context` — return the active trace context (for re-attaching across fiber/thread boundaries), or `nil` when there is none.
@@ -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
 
@@ -166,12 +166,43 @@ class AWSAgent < Riffer::Agent
166
166
  end
167
167
  ```
168
168
 
169
+ ## Reasoning Models
170
+
171
+ Claude models on Bedrock can think before they answer. Enable extended thinking through `additional_model_request_fields`:
172
+
173
+ ```ruby
174
+ class ThinkAgent < Riffer::Agent
175
+ model 'amazon_bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0'
176
+ model_options inference_config: {max_tokens: 2048},
177
+ additional_model_request_fields: {thinking: {type: "enabled", budget_tokens: 1024}}
178
+ end
179
+
180
+ ThinkAgent.new.stream('What is 2+2? Think step by step.').each do |event|
181
+ case event
182
+ when Riffer::StreamEvents::ReasoningDelta
183
+ print "[reasoning] #{event.content}"
184
+ when Riffer::StreamEvents::TextDelta
185
+ print event.content
186
+ end
187
+ end
188
+ ```
189
+
190
+ The reasoning is kept on the assistant message as [reasoning parts](../MESSAGES.md#reasoning), whether you call `generate` or `stream`. Read it with `response.reasoning`, or as plain text with `reasoning_text` on the message. Bedrock sometimes redacts part of Claude's reasoning. Those parts are stored and sent back like any other, but they have no readable text, so `reasoning_text` leaves them out.
191
+
192
+ ### Reasoning Replay
193
+
194
+ Riffer sends the reasoning back to Bedrock on every later turn. How much of it Claude uses depends on the model; some only use the reasoning from the current tool-calling turn. For tool calls, sending it back is required: when thinking is enabled, Claude needs its earlier reasoning back to carry on after a tool result. You don't have to do anything; it happens as long as the assistant messages stay in the history.
195
+
196
+ If you persist sessions, keep the `reasoning` key when you store messages (see [Messages — Reasoning](../MESSAGES.md#reasoning)). If you drop it, later turns lose the model's earlier reasoning, and a tool-calling turn with thinking enabled may be rejected.
197
+
198
+ Only reasoning that Bedrock produced is sent back to Bedrock. A conversation that switches providers midway still works: reasoning from other providers is kept on the messages but left out of Bedrock requests.
199
+
169
200
  ## File Support
170
201
 
171
202
  Bedrock accepts file attachments either as raw bytes, or as `s3://` URIs passed straight through to Converse — Bedrock fetches the S3 object itself:
172
203
 
173
204
  ```ruby
174
- file = Riffer::Messages::FilePart.from_url("s3://my-bucket/document.pdf", media_type: "application/pdf")
205
+ file = Riffer::Messages::User::FilePart.from_url("s3://my-bucket/document.pdf", media_type: "application/pdf")
175
206
  response = provider.generate_text(
176
207
  prompt: "Summarize this document",
177
208
  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")
@@ -276,6 +276,58 @@ yielder << Riffer::StreamEvents::FinishReasonDone.new(finish_reason: :stop, raw_
276
276
 
277
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
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
+
279
331
  ## Trace Provider Name
280
332
 
281
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:
@@ -330,7 +382,7 @@ class Riffer::Providers::MyProvider < Riffer::Providers::Base
330
382
  messages: convert_messages(conversation),
331
383
  system: system_message,
332
384
  max_tokens: options[:max_tokens] || 4096,
333
- **options.except(:tools, :max_tokens)
385
+ **options.except(:tools, :max_tokens, :tags)
334
386
  }
335
387
 
336
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:
@@ -173,7 +173,7 @@ end
173
173
 
174
174
  ## Reasoning Models
175
175
 
176
- Reasoning models surface their thought process via OpenRouter's normalised `reasoning` field. Enable it with the `reasoning` option:
176
+ Reasoning models surface their thought process via OpenRouter's normalised `reasoning_details` field. Enable it with the `reasoning` option:
177
177
 
178
178
  ```ruby
179
179
  class ThinkAgent < Riffer::Agent
@@ -191,6 +191,23 @@ ThinkAgent.new.stream('What is 2+2? Think step by step.').each do |event|
191
191
  end
192
192
  ```
193
193
 
194
+ ### Reasoning Replay
195
+
196
+ Each entry in OpenRouter's `reasoning_details` becomes a [`ReasoningPart`](../MESSAGES.md#reasoning) on the assistant message, on both `generate_text` and `stream_text`, with nothing dropped:
197
+
198
+ | `reasoning_details` field | `ReasoningPart` field |
199
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------- |
200
+ | `type` | `type`: `reasoning.text` → `:text`, `reasoning.summary` → `:summary`, `reasoning.encrypted` → `:encrypted` |
201
+ | `text` / `summary` | `text` |
202
+ | `data` | `data` |
203
+ | `signature` | `signature` |
204
+ | `id` | `id` |
205
+ | `format` | `format` (e.g. `"anthropic-claude-v1"`, `"openai-responses-v1"`, `"unknown"`) |
206
+
207
+ When streaming, OpenRouter splits one block into many fragments that share an `index`. The provider concatenates their `text`, `summary`, and `data` and keeps the `signature`, `id`, and `format` that arrive along the way, so each block ends as one `ReasoningDone` part. Each non-empty `text` or `summary` fragment is also yielded as a `ReasoningDelta`. Detail types riffer doesn't know are skipped.
208
+
209
+ On the next request, the assistant message's parts go back as `reasoning_details` in their original order and unchanged. This is what lets Anthropic and Gemini models continue signed thinking across a tool-call loop, and lets OpenAI models reuse their encrypted reasoning. Following the [replay contract](../MESSAGES.md#reasoning), only parts whose `format` is one OpenRouter documents are sent: `unknown`, `openai-responses-v1`, `azure-openai-responses-v1`, `bedrock-openai-responses-v1`, `bedrock-xai-responses-v1`, `xai-responses-v1`, `meta-responses-v1`, `anthropic-claude-v1`, and `google-gemini-v1` (listed in `Riffer::Providers::OpenRouter::REASONING_FORMATS`). Parts with no `format`, or one produced by another adapter such as `mock-v1`, are skipped. The `index` is not stored, since the parts' order already carries it.
210
+
194
211
  ## Routing & Fallbacks
195
212
 
196
213
  Survive an upstream outage by chaining models:
data/docs-site/build.rb CHANGED
@@ -1,10 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Builds the docs site — landing page, guide pages, 404, and assets — into
5
- # _site/ at the repo root. Pages are declared in manifest.yml; the build fails
6
- # if the manifest and docs/**/*.md ever disagree.
7
-
8
4
  require "erb"
9
5
  require "fileutils"
10
6
  require "yaml"
@@ -89,8 +85,6 @@ def build_groups(manifest)
89
85
  end
90
86
  end
91
87
 
92
- # Numbering restarts per docs/ subdirectory, so the providers pages read as
93
- # their own sequence rather than continuing the main chapters.
94
88
  def chapter_numbers(manifest)
95
89
  manifest.
96
90
  flat_map { |group| group[:pages] }.
data/docs-site/check.rb CHANGED
@@ -1,9 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- # Validates every internal link and anchor in the built _site/ HTML. Exits
5
- # non-zero with a list of broken links on failure.
6
-
7
4
  SITE = Pathname(__dir__).join("../_site").expand_path
8
5
 
9
6
  EXTERNAL = %r{\A(?:https?:|mailto:|//)}
@@ -33,11 +30,10 @@ def ids(html)
33
30
  html.scan(/\bid="([^"]+)"/).flatten
34
31
  end
35
32
 
36
- # Links into /api/ are skipped: RDoc builds that tree in a separate task, so
37
- # it is absent when only the site has been built.
38
33
  def page_errors(file, id_index)
39
34
  links(file.read).
40
35
  grep_v(EXTERNAL).
36
+ # RDoc builds /api/ in a separate task, so it is absent when only the site has been built.
41
37
  reject { |link| link.start_with?("/api/") }.
42
38
  filter_map { |link| link_error(file, link, id_index) }
43
39
  end
@@ -1,47 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Typed configuration object holding every class-level DSL setting on a
5
- # Riffer::Agent subclass. Procs are stored unresolved and resolved per-instance
6
- # later.
7
4
  class Riffer::Agent::Config
8
5
  DEFAULT_MAX_STEPS = 16 #: Integer
9
6
 
10
- # The configured agent identifier.
11
- attr_reader :identifier #: String? # @dynamic identifier
7
+ # @rbs @tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?
12
8
 
13
- # The configured model.
9
+ attr_reader :identifier #: String? # @dynamic identifier
14
10
  attr_reader :model #: (String | Proc)? # @dynamic model
15
-
16
- # The configured instructions.
17
11
  attr_reader :instructions #: (String | Proc)? # @dynamic instructions
18
-
19
- # Options passed to generate_text/stream_text.
20
12
  attr_accessor :model_options #: Hash[Symbol, untyped] # @dynamic model_options, model_options=
21
-
22
- # The configured structured-output schema.
23
13
  attr_reader :structured_output #: Riffer::Params? # @dynamic structured_output
24
-
25
- # The maximum number of LLM call steps in the tool-use loop.
26
14
  attr_accessor :max_steps #: Numeric? # @dynamic max_steps, max_steps=
27
-
28
- # The configured tools.
29
15
  attr_accessor :tools_config #: (Array[singleton(Riffer::Tool)] | Proc)? # @dynamic tools_config, tools_config=
30
-
31
- # The accumulated +use_mcp+ tag configurations.
32
16
  attr_reader :mcp_configs #: Array[Hash[Symbol, untyped]] # @dynamic mcp_configs
33
-
34
- # The configured tool runtime.
35
- attr_reader :tool_runtime #: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc) # @dynamic tool_runtime
36
-
37
- # The configured skills.
38
17
  attr_accessor :skills_config #: Riffer::Skills::Config? # @dynamic skills_config, skills_config=
39
-
40
- # Registered guardrail entries keyed by phase.
41
18
  attr_reader :guardrails #: Hash[Symbol, Array[Hash[Symbol, untyped]]] # @dynamic guardrails
42
19
 
43
- # Builds a new Config. Raises Riffer::ArgumentError if +model+ or
44
- # +instructions+ is invalid (e.g. an empty string).
45
20
  #--
46
21
  #: (
47
22
  # ?identifier: String?,
@@ -52,7 +27,7 @@ class Riffer::Agent::Config
52
27
  # ?max_steps: Numeric?,
53
28
  # ?tools_config: (Array[singleton(Riffer::Tool)] | Proc)?,
54
29
  # ?mcp_configs: Array[Hash[Symbol, untyped]],
55
- # ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc),
30
+ # ?tool_runtime: (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)?,
56
31
  # ?skills_config: Riffer::Skills::Config?,
57
32
  # ?guardrails: Hash[Symbol, Array[Hash[Symbol, untyped]]]
58
33
  # ) -> void
@@ -65,7 +40,7 @@ class Riffer::Agent::Config
65
40
  max_steps: DEFAULT_MAX_STEPS,
66
41
  tools_config: nil,
67
42
  mcp_configs: [],
68
- tool_runtime: Riffer.config.tool_runtime,
43
+ tool_runtime: nil,
69
44
  skills_config: nil,
70
45
  guardrails: { before: [], after: [] }
71
46
  )
@@ -79,17 +54,15 @@ class Riffer::Agent::Config
79
54
  self.model = model
80
55
  self.instructions = instructions
81
56
  self.structured_output = structured_output
82
- self.tool_runtime = tool_runtime
57
+ self.tool_runtime = tool_runtime if tool_runtime
83
58
  end
84
59
 
85
- # Sets +identifier+, coercing the value to String.
86
60
  #--
87
61
  #: (untyped) -> String?
88
62
  def identifier=(value)
89
63
  @identifier = value&.to_s
90
64
  end
91
65
 
92
- # Sets +structured_output+. Raises Riffer::ArgumentError on an invalid value.
93
66
  #--
94
67
  #: (Riffer::Params?) -> Riffer::Params?
95
68
  def structured_output=(value)
@@ -100,7 +73,14 @@ class Riffer::Agent::Config
100
73
  @structured_output = value
101
74
  end
102
75
 
103
- # Sets +tool_runtime+. Raises Riffer::ArgumentError on an invalid value.
76
+ #--
77
+ #: () -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
78
+ def tool_runtime
79
+ # Resolved here rather than at construction so a copy can tell an inherited
80
+ # runtime from a defaulted one.
81
+ @tool_runtime || Riffer.config.tool_runtime
82
+ end
83
+
104
84
  #--
105
85
  #: ((singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)) -> (singleton(Riffer::Tools::Runtime) | Riffer::Tools::Runtime | Proc)
106
86
  def tool_runtime=(value)
@@ -114,8 +94,6 @@ class Riffer::Agent::Config
114
94
  @tool_runtime = value
115
95
  end
116
96
 
117
- # Sets +model+. Raises Riffer::ArgumentError on an invalid value (e.g. an
118
- # empty string).
119
97
  #--
120
98
  #: ((String | Proc)?) -> (String | Proc)?
121
99
  def model=(value)
@@ -123,8 +101,6 @@ class Riffer::Agent::Config
123
101
  @model = value
124
102
  end
125
103
 
126
- # Sets +instructions+. Raises Riffer::ArgumentError on an invalid value (e.g.
127
- # an empty string).
128
104
  #--
129
105
  #: ((String | Proc)?) -> (String | Proc)?
130
106
  def instructions=(value)
@@ -132,8 +108,6 @@ class Riffer::Agent::Config
132
108
  @instructions = value
133
109
  end
134
110
 
135
- # Appends an MCP tag entry to +mcp_configs+.
136
- #
137
111
  #--
138
112
  #: (String | Symbol, ?progressive: bool) -> Array[Hash[Symbol, untyped]]
139
113
  def add_mcp(tag, progressive: true)
@@ -142,9 +116,6 @@ class Riffer::Agent::Config
142
116
  @mcp_configs << { tags: [tag.to_sym], progressive: progressive }
143
117
  end
144
118
 
145
- # Appends a guardrail entry to +guardrails+ for the given phase; +:around+
146
- # appends to both +:before+ and +:after+. Raises Riffer::ArgumentError unless
147
- # +phase+ is :before, :after, or :around.
148
119
  #--
149
120
  #: (Symbol, klass: singleton(Riffer::Guardrail), ?options: Hash[Symbol, untyped]) -> void
150
121
  def add_guardrail(phase, klass:, options: {})
@@ -167,8 +138,6 @@ class Riffer::Agent::Config
167
138
  end
168
139
  end
169
140
 
170
- # Returns the guardrail entries for the given phase, or +[]+ if none.
171
- #
172
141
  #--
173
142
  #: (Symbol) -> Array[Hash[Symbol, untyped]]
174
143
  def guardrails_for(phase)
@@ -177,6 +146,20 @@ class Riffer::Agent::Config
177
146
 
178
147
  private
179
148
 
149
+ #--
150
+ #: (Riffer::Agent::Config) -> void
151
+ def initialize_copy(source)
152
+ super
153
+ # A shallow copy would share collections, letting a declaration on either
154
+ # config reach the other; entries nest (mcp +:tags+, guardrail +:options+).
155
+ @model_options = Riffer::Helpers::DeepDup.call(source.model_options)
156
+ @mcp_configs = Riffer::Helpers::DeepDup.call(source.mcp_configs)
157
+ @guardrails = Riffer::Helpers::DeepDup.call(source.guardrails)
158
+ @tools_config = Riffer::Helpers::DeepDup.call(source.tools_config)
159
+ @skills_config = source.skills_config&.dup
160
+ @structured_output = source.structured_output&.dup
161
+ end
162
+
180
163
  #--
181
164
  #: (untyped, String) -> void
182
165
  def validate_string_or_proc!(value, name)