riffer 0.47.0 → 0.47.2

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 (80) 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 +16 -0
  5. data/Rakefile +1 -1
  6. data/Steepfile +1 -15
  7. data/docs/AGENTS.md +2 -2
  8. data/docs/STREAM_EVENTS.md +23 -0
  9. data/docs/providers/CUSTOM_PROVIDERS.md +2 -0
  10. data/lib/riffer/agent/config.rb +11 -11
  11. data/lib/riffer/agent/outcome.rb +2 -2
  12. data/lib/riffer/agent/response.rb +9 -9
  13. data/lib/riffer/agent/run.rb +0 -1
  14. data/lib/riffer/agent/session.rb +1 -1
  15. data/lib/riffer/agent/structured_output/result.rb +2 -2
  16. data/lib/riffer/agent/structured_output.rb +1 -1
  17. data/lib/riffer/agent.rb +11 -11
  18. data/lib/riffer/config.rb +30 -30
  19. data/lib/riffer/evals/judge.rb +2 -3
  20. data/lib/riffer/evals/result.rb +6 -6
  21. data/lib/riffer/evals/run_result.rb +1 -1
  22. data/lib/riffer/evals/scenario_result.rb +6 -6
  23. data/lib/riffer/guardrails/modification.rb +3 -3
  24. data/lib/riffer/guardrails/result.rb +3 -3
  25. data/lib/riffer/guardrails/runner.rb +4 -4
  26. data/lib/riffer/guardrails/tripwire.rb +4 -4
  27. data/lib/riffer/mcp/manifest.rb +5 -5
  28. data/lib/riffer/mcp/registration.rb +1 -1
  29. data/lib/riffer/mcp/search_tool.rb +1 -1
  30. data/lib/riffer/messages/assistant.rb +5 -5
  31. data/lib/riffer/messages/base.rb +2 -4
  32. data/lib/riffer/messages/file_part.rb +4 -7
  33. data/lib/riffer/messages/tool.rb +4 -4
  34. data/lib/riffer/messages/user.rb +1 -1
  35. data/lib/riffer/params/param.rb +29 -10
  36. data/lib/riffer/params.rb +3 -1
  37. data/lib/riffer/providers/amazon_bedrock.rb +29 -2
  38. data/lib/riffer/providers/anthropic.rb +7 -0
  39. data/lib/riffer/providers/base.rb +1 -2
  40. data/lib/riffer/providers/finish_reason.rb +2 -2
  41. data/lib/riffer/providers/mock.rb +1 -1
  42. data/lib/riffer/providers/open_ai.rb +8 -0
  43. data/lib/riffer/providers/repository.rb +0 -4
  44. data/lib/riffer/providers/token_usage.rb +5 -5
  45. data/lib/riffer/skills/adapter.rb +1 -1
  46. data/lib/riffer/skills/context.rb +4 -7
  47. data/lib/riffer/skills/filesystem_backend.rb +1 -2
  48. data/lib/riffer/skills/frontmatter.rb +4 -4
  49. data/lib/riffer/skills/xml_adapter.rb +1 -1
  50. data/lib/riffer/stream_events/base.rb +1 -1
  51. data/lib/riffer/stream_events/finish_reason_done.rb +2 -2
  52. data/lib/riffer/stream_events/guardrail_modification.rb +1 -1
  53. data/lib/riffer/stream_events/guardrail_tripwire.rb +1 -1
  54. data/lib/riffer/stream_events/interrupt.rb +2 -2
  55. data/lib/riffer/stream_events/reasoning_delta.rb +1 -1
  56. data/lib/riffer/stream_events/reasoning_done.rb +1 -1
  57. data/lib/riffer/stream_events/skill_activation.rb +1 -1
  58. data/lib/riffer/stream_events/text_delta.rb +1 -1
  59. data/lib/riffer/stream_events/text_done.rb +1 -1
  60. data/lib/riffer/stream_events/token_usage_done.rb +1 -1
  61. data/lib/riffer/stream_events/tool_call_delta.rb +3 -3
  62. data/lib/riffer/stream_events/tool_call_done.rb +4 -4
  63. data/lib/riffer/stream_events/web_search_done.rb +2 -2
  64. data/lib/riffer/stream_events/web_search_status.rb +3 -3
  65. data/lib/riffer/tools/response.rb +4 -4
  66. data/lib/riffer/tracing/stream_recorder.rb +6 -6
  67. data/lib/riffer/version.rb +1 -1
  68. data/lib/riffer.rb +7 -0
  69. data/rbs_collection.lock.yaml +320 -0
  70. data/rbs_collection.yaml +12 -0
  71. data/sig/_private/anthropic.rbs +5 -4
  72. data/sig/_private/aws-sdk-core/event_error.rbs +15 -0
  73. data/sig/generated/riffer/params/param.rbs +10 -1
  74. data/sig/generated/riffer/params.rbs +2 -0
  75. data/sig/generated/riffer/providers/amazon_bedrock.rbs +10 -1
  76. data/sig/generated/riffer/providers/repository.rbs +0 -2
  77. data/sig/generated/riffer.rbs +8 -0
  78. metadata +4 -3
  79. data/sig/_private/async.rbs +0 -28
  80. data/sig/_private/minitest.rbs +0 -9
@@ -5,16 +5,16 @@
5
5
  # any blocks, execution stops and a tripwire is returned.
6
6
  class Riffer::Guardrails::Runner
7
7
  # The guardrail configs to execute.
8
- attr_reader :guardrail_configs #: Array[Hash[Symbol, untyped]]
8
+ attr_reader :guardrail_configs #: Array[Hash[Symbol, untyped]] # @dynamic guardrail_configs
9
9
 
10
10
  # The execution phase (:before or :after).
11
- attr_reader :phase #: Symbol
11
+ attr_reader :phase #: Symbol # @dynamic phase
12
12
 
13
13
  # The context passed to guardrails.
14
- attr_reader :context #: untyped
14
+ attr_reader :context #: untyped # @dynamic context
15
15
 
16
16
  # The normalized per-call tags, stamped as +riffer.tag.*+ on guardrail spans.
17
- attr_reader :tags #: Hash[String, String]
17
+ attr_reader :tags #: Hash[String, String] # @dynamic tags
18
18
 
19
19
  #--
20
20
  #: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
@@ -6,16 +6,16 @@ class Riffer::Guardrails::Tripwire
6
6
  PHASES = Riffer::Guardrails::PHASES #: Array[Symbol]
7
7
 
8
8
  # The reason for blocking.
9
- attr_reader :reason #: String
9
+ attr_reader :reason #: String # @dynamic reason
10
10
 
11
11
  # The guardrail class that triggered the block.
12
- attr_reader :guardrail #: singleton(Riffer::Guardrail)
12
+ attr_reader :guardrail #: singleton(Riffer::Guardrail) # @dynamic guardrail
13
13
 
14
14
  # The phase when the block occurred (:before or :after).
15
- attr_reader :phase #: Symbol
15
+ attr_reader :phase #: Symbol # @dynamic phase
16
16
 
17
17
  # Optional metadata about the block.
18
- attr_reader :metadata #: Hash[Symbol, untyped]?
18
+ attr_reader :metadata #: Hash[Symbol, untyped]? # @dynamic metadata
19
19
 
20
20
  # Raises Riffer::ArgumentError if +phase+ is invalid.
21
21
  #--
@@ -6,20 +6,20 @@ require "uri"
6
6
  # Holds the configuration for a single MCP server.
7
7
  class Riffer::Mcp::Manifest
8
8
  # Identifier used as the registration key and generated-agent identifier.
9
- attr_reader :name #: String
9
+ attr_reader :name #: String # @dynamic name
10
10
 
11
11
  # Tags for matching +use_mcp+.
12
- attr_reader :tags #: Array[Symbol]
12
+ attr_reader :tags #: Array[Symbol] # @dynamic tags
13
13
 
14
14
  # HTTPS URL passed to the MCP transport.
15
- attr_reader :endpoint #: String
15
+ attr_reader :endpoint #: String # @dynamic endpoint
16
16
 
17
17
  # Headers (or a Proc) resolved once when building the discovery client.
18
- attr_reader :discovery_headers #: (Hash[String, untyped] | ::Proc)?
18
+ attr_reader :discovery_headers #: (Hash[String, untyped] | ::Proc)? # @dynamic discovery_headers
19
19
 
20
20
  # Optional hint (+:global+/+:tenant+/+:user+) for whether invocation
21
21
  # credentials depend on tenant/user keys in +context+.
22
- attr_reader :credentials_scope #: Symbol?
22
+ attr_reader :credentials_scope #: Symbol? # @dynamic credentials_scope
23
23
 
24
24
  # Raises Riffer::ArgumentError unless +name+ is present and +endpoint+ is a
25
25
  # valid HTTPS URL.
@@ -9,7 +9,7 @@ class Riffer::Mcp::Registration
9
9
  # @rbs @mutex: Thread::Mutex
10
10
 
11
11
  # The manifest that describes this server.
12
- attr_reader :manifest #: Riffer::Mcp::Manifest
12
+ attr_reader :manifest #: Riffer::Mcp::Manifest # @dynamic manifest
13
13
 
14
14
  # Generated Riffer::Mcp::Tool subclasses.
15
15
  #
@@ -8,7 +8,7 @@ class Riffer::Mcp::SearchTool < Riffer::Tool
8
8
  # Successful search response carrying the matched tool classes.
9
9
  class Result < Riffer::Tools::Response
10
10
  # Tool classes that matched the search query.
11
- attr_reader :discovered_tools #: Array[singleton(Riffer::Tool)]
11
+ attr_reader :discovered_tools #: Array[singleton(Riffer::Tool)] # @dynamic discovered_tools
12
12
 
13
13
  #--
14
14
  #: (String, Array[singleton(Riffer::Tool)]) -> void
@@ -7,21 +7,21 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
7
7
  ToolCall = Struct.new(:call_id, :name, :arguments)
8
8
 
9
9
  # Array of tool calls requested by the assistant.
10
- attr_reader :tool_calls #: Array[Riffer::Messages::Assistant::ToolCall]
10
+ attr_reader :tool_calls #: Array[Riffer::Messages::Assistant::ToolCall] # @dynamic tool_calls
11
11
 
12
12
  # Token usage data for this response.
13
- attr_reader :token_usage #: Riffer::Providers::TokenUsage?
13
+ attr_reader :token_usage #: Riffer::Providers::TokenUsage? # @dynamic token_usage
14
14
 
15
15
  # Parsed structured output hash, or nil when not applicable.
16
- attr_reader :structured_output #: Hash[Symbol, untyped]?
16
+ attr_reader :structured_output #: Hash[Symbol, untyped]? # @dynamic structured_output
17
17
 
18
18
  # Normalized reason the provider finished this response, when reported (see
19
19
  # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
20
- attr_reader :finish_reason #: Symbol?
20
+ attr_reader :finish_reason #: Symbol? # @dynamic finish_reason
21
21
 
22
22
  # The provider's raw finish-reason value behind +finish_reason+, when one
23
23
  # exists on the wire.
24
- attr_reader :finish_reason_raw #: String?
24
+ attr_reader :finish_reason_raw #: String? # @dynamic finish_reason_raw
25
25
 
26
26
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
27
27
  # normalized vocabulary.
@@ -12,8 +12,6 @@ class Riffer::Messages::Base
12
12
  def self.from_hash(msg)
13
13
  return msg if msg.is_a?(Riffer::Messages::Base)
14
14
 
15
- raise Riffer::ArgumentError, "Message must be a Hash or Message object, got #{msg.class}" unless msg.is_a?(Hash)
16
-
17
15
  raise Riffer::ArgumentError, "Message hash must include a 'role' key" if msg[:role].nil? || msg[:role].empty?
18
16
 
19
17
  case msg[:role].to_sym
@@ -42,10 +40,10 @@ class Riffer::Messages::Base
42
40
  end
43
41
 
44
42
  # The message content.
45
- attr_reader :content #: String
43
+ attr_reader :content #: String # @dynamic content
46
44
 
47
45
  # The message id, or nil when +Riffer.config.message_id_strategy+ is +:none+.
48
- attr_reader :id #: String?
46
+ attr_reader :id #: String? # @dynamic id
49
47
 
50
48
  #--
51
49
  #: (String, ?id: String?) -> void
@@ -29,13 +29,13 @@ class Riffer::Messages::FilePart
29
29
  SHA256_PATTERN = /\A[0-9a-f]{64}\z/i #: Regexp
30
30
 
31
31
  # The MIME type of the file.
32
- attr_reader :media_type #: String
32
+ attr_reader :media_type #: String # @dynamic media_type
33
33
 
34
34
  # The filename, if available.
35
- attr_reader :filename #: String?
35
+ attr_reader :filename #: String? # @dynamic filename
36
36
 
37
37
  # The expected SHA-256 of the file contents, if the caller supplied one.
38
- attr_reader :sha256 #: String?
38
+ attr_reader :sha256 #: String? # @dynamic sha256
39
39
 
40
40
  # Raises Riffer::ArgumentError unless +data+ or +url+ is given and
41
41
  # +media_type+ is supported.
@@ -66,8 +66,7 @@ class Riffer::Messages::FilePart
66
66
  def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
67
67
  unless media_type
68
68
  ext = ::File.extname(URI.parse(url).path.to_s).downcase
69
- media_type = MEDIA_TYPES[ext]
70
- raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" unless media_type
69
+ media_type = MEDIA_TYPES.fetch(ext) { raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" }
71
70
  end
72
71
 
73
72
  new(url: url, media_type: media_type, filename: filename, sha256: sha256)
@@ -81,8 +80,6 @@ class Riffer::Messages::FilePart
81
80
  def self.from_hash(file)
82
81
  return file if file.is_a?(Riffer::Messages::FilePart)
83
82
 
84
- raise Riffer::ArgumentError, "File must be a Hash or FilePart object, got #{file.class}" unless file.is_a?(Hash)
85
-
86
83
  url = file[:url]
87
84
  data = file[:data]
88
85
  media_type = file[:media_type]
@@ -4,17 +4,17 @@
4
4
  # Represents a tool execution result in a conversation.
5
5
  class Riffer::Messages::Tool < Riffer::Messages::Base
6
6
  # The ID of the tool call this result responds to.
7
- attr_reader :tool_call_id #: String
7
+ attr_reader :tool_call_id #: String # @dynamic tool_call_id
8
8
 
9
9
  # The name of the tool that was called.
10
- attr_reader :name #: String
10
+ attr_reader :name #: String # @dynamic name
11
11
 
12
12
  # The error message if the tool execution failed.
13
- attr_reader :error #: String?
13
+ attr_reader :error #: String? # @dynamic error
14
14
 
15
15
  # The type of error (:unknown_tool, :validation_error, :execution_error,
16
16
  # :timeout_error, :unhandled_error).
17
- attr_reader :error_type #: Symbol?
17
+ attr_reader :error_type #: Symbol? # @dynamic error_type
18
18
 
19
19
  #--
20
20
  #: (String, tool_call_id: String, name: String, ?id: String?, ?error: String?, ?error_type: Symbol?) -> void
@@ -4,7 +4,7 @@
4
4
  # Represents a user message in a conversation.
5
5
  class Riffer::Messages::User < Riffer::Messages::Base
6
6
  # File attachments for this message.
7
- attr_reader :files #: Array[Riffer::Messages::FilePart]
7
+ attr_reader :files #: Array[Riffer::Messages::FilePart] # @dynamic files
8
8
 
9
9
  #--
10
10
  #: (String, ?id: String?, ?files: Array[Riffer::Messages::FilePart]) -> void
@@ -31,28 +31,28 @@ class Riffer::Params::Param
31
31
  }.freeze #: Hash[String, Module]
32
32
 
33
33
  # The parameter name.
34
- attr_reader :name #: Symbol
34
+ attr_reader :name #: Symbol # @dynamic name
35
35
 
36
36
  # The Ruby type.
37
- attr_reader :type #: Module
37
+ attr_reader :type #: Module # @dynamic type
38
38
 
39
39
  # Whether the parameter is required.
40
- attr_reader :required #: bool
40
+ attr_reader :required #: bool # @dynamic required
41
41
 
42
42
  # The parameter description, if any.
43
- attr_reader :description #: String?
43
+ attr_reader :description #: String? # @dynamic description
44
44
 
45
45
  # Allowed values, if constrained.
46
- attr_reader :enum #: Array[untyped]?
46
+ attr_reader :enum #: Array[untyped]? # @dynamic enum
47
47
 
48
48
  # The default value, if any.
49
- attr_reader :default #: untyped
49
+ attr_reader :default #: untyped # @dynamic default
50
50
 
51
51
  # Element type for a typed array (+of:+).
52
- attr_reader :item_type #: Module?
52
+ attr_reader :item_type #: Module? # @dynamic item_type
53
53
 
54
54
  # Nested Params for object / array-of-object types.
55
- attr_reader :nested_params #: Riffer::Params?
55
+ attr_reader :nested_params #: Riffer::Params? # @dynamic nested_params
56
56
 
57
57
  # Reconstructs a Param from a single JSON Schema property. Raises
58
58
  # Riffer::ArgumentError on a type outside the Params-expressible subset.
@@ -140,10 +140,14 @@ class Riffer::Params::Param
140
140
  # params are made nullable (<tt>["type", "null"]</tt>) so strict providers
141
141
  # distinguish absent from present; optional params with an +enum+ use +anyOf+
142
142
  # instead, since providers like Anthropic reject
143
- # <tt>{"type": ["string", "null"], "enum": [...]}</tt>.
143
+ # <tt>{"type": ["string", "null"], "enum": [...]}</tt>. Raises
144
+ # Riffer::ArgumentError when +strict+ and a Hash param has no block or an
145
+ # Array param has neither a block nor <tt>of:</tt>, since strict providers
146
+ # reject objects without +properties+ and arrays without +items+.
144
147
  #--
145
148
  #: (?strict: bool) -> Hash[Symbol, untyped]
146
149
  def to_json_schema(strict: false)
150
+ validate_strict_shape! if strict
147
151
  nullable = strict && !required
148
152
 
149
153
  if nullable && enum
@@ -168,9 +172,24 @@ class Riffer::Params::Param
168
172
  elsif self.type == Array && item_type
169
173
  schema[:items] = { type: TYPE_MAPPINGS[item_type] }
170
174
  elsif self.type == Hash && nested_params
171
- schema.merge!(nested_params.to_json_schema(strict: strict))
175
+ # The nested schema carries its own type: "object", which would clobber a nullable union.
176
+ schema.merge!(nested_params.to_json_schema(strict: strict).except(:type))
172
177
  end
173
178
 
174
179
  schema
175
180
  end
181
+
182
+ private
183
+
184
+ #--
185
+ #: () -> void
186
+ def validate_strict_shape!
187
+ if type == Hash && nested_params.nil?
188
+ raise Riffer::ArgumentError,
189
+ "#{name}: a Hash param requires a block defining its properties under strict schemas"
190
+ elsif type == Array && nested_params.nil? && item_type.nil?
191
+ raise Riffer::ArgumentError,
192
+ "#{name}: an Array param requires a block or of: defining its items under strict schemas"
193
+ end
194
+ end
176
195
  end
data/lib/riffer/params.rb CHANGED
@@ -11,7 +11,7 @@
11
11
  #
12
12
  class Riffer::Params
13
13
  # The defined parameters.
14
- attr_reader :parameters #: Array[Riffer::Params::Param]
14
+ attr_reader :parameters #: Array[Riffer::Params::Param] # @dynamic parameters
15
15
 
16
16
  #--
17
17
  #: () -> void
@@ -128,6 +128,8 @@ class Riffer::Params
128
128
  # Converts all parameters to JSON Schema format. When +strict+ is true, every
129
129
  # property is listed in +required+ and optional ones are made nullable
130
130
  # instead, satisfying providers that enforce strict structured output schemas.
131
+ # Raises Riffer::ArgumentError when +strict+ and a Hash param (at any depth)
132
+ # has no block or an Array param has neither a block nor <tt>of:</tt>.
131
133
  #--
132
134
  #: (?strict: bool) -> Hash[Symbol, untyped]
133
135
  def to_json_schema(strict: false)
@@ -154,7 +154,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
154
154
  #: (untyped) -> Hash[Symbol, untyped]
155
155
  def build_cache_point(cache_control)
156
156
  point = { type: "default" } #: Hash[Symbol, untyped]
157
- ttl = cache_control.is_a?(Hash) ? cache_control[:ttl] : nil
157
+ ttl = cache_control[:ttl] if cache_control.is_a?(Hash)
158
158
  point[:ttl] = ttl if ttl
159
159
  point
160
160
  end
@@ -252,6 +252,8 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
252
252
  tool_call: nil,
253
253
  } #: Hash[Symbol, untyped]
254
254
 
255
+ stream_completed = false
256
+
255
257
  client.converse_stream(**params) do |stream|
256
258
  stream.on_event do |event|
257
259
  case event
@@ -264,20 +266,45 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
264
266
  handle_content_block_stop_text_delta(event, state: current_state, yielder: yielder) if current_state[:text]
265
267
  handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder) if current_state[:tool_call]
266
268
  when Aws::BedrockRuntime::Types::MessageStopEvent
269
+ stream_completed = true
267
270
  yield_finish_reason(yielder, build_finish_reason(event.stop_reason))
268
271
  when Aws::BedrockRuntime::Types::ConverseStreamMetadataEvent
269
272
  handle_metadata_usage(event, state: current_state, yielder: yielder) if event.usage
273
+ when Aws::Errors::EventError
274
+ # The SDK turns an event-stream +:message-type: error+ frame into an
275
+ # EventError instance and hands it to this block as an event; it is
276
+ # never raised. Re-raise it here so the failure surfaces instead of
277
+ # truncating the stream.
278
+ raise_stream_event_error!(event)
270
279
  else
271
280
  raise_if_stream_exception!(event)
272
281
  end
273
282
  end
274
283
  end
284
+
285
+ return if stream_completed
286
+
287
+ raise Riffer::IncompleteStreamError, "Bedrock ConverseStream ended without a messageStop event"
288
+ end
289
+
290
+ # Re-raises an +Aws::Errors::EventError+ event with a message built from its
291
+ # error code and message. The SDK's own +#message+ is just the class name,
292
+ # so without this the failure would be unreadable.
293
+ #--
294
+ #: (untyped) -> void
295
+ def raise_stream_event_error!(event)
296
+ details = [event.error_code, event.error_message].compact.join(": ")
297
+ details = "Bedrock ConverseStream error event" if details.empty?
298
+
299
+ raise event.exception(details)
275
300
  end
276
301
 
277
302
  # Re-raises a Bedrock stream-exception event as the matching
278
303
  # +Aws::BedrockRuntime::Errors+ class. ConverseStream delivers API errors on
279
304
  # the same channel as content, so without this a mid-stream failure would
280
- # silently end the stream with no content.
305
+ # silently end the stream with no content. Non-exception events that we do
306
+ # not consume (including the SDK's +:unknown_event+ struct) are ignored for
307
+ # forward compatibility.
281
308
  #--
282
309
  #: (untyped) -> void
283
310
  def raise_if_stream_exception!(event)
@@ -211,6 +211,8 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
211
211
  web_search_query: nil,
212
212
  } #: Hash[Symbol, untyped]
213
213
 
214
+ stream_completed = false
215
+
214
216
  # Workaround for anthropics/anthropic-sdk-ruby#182: force identity
215
217
  # encoding so Net::HTTP/Zlib doesn't buffer SSE chunks until EOF.
216
218
  stream = client.messages.stream(
@@ -247,6 +249,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
247
249
  handle_content_block_stop_web_search_result(event, state: current_state, yielder: yielder)
248
250
  end
249
251
  when ::Anthropic::Helpers::Streaming::MessageStopEvent
252
+ stream_completed = true
250
253
  handle_message_stop(event, accumulated_message: stream.accumulated_message, yielder: yielder)
251
254
  end
252
255
  end
@@ -256,6 +259,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
256
259
  # socket leaks until GC. close is idempotent and a no-op after EOF.
257
260
  stream.close
258
261
  end
262
+
263
+ return if stream_completed
264
+
265
+ raise Riffer::IncompleteStreamError, "Anthropic stream ended without a message_stop event"
259
266
  end
260
267
 
261
268
  #--
@@ -30,10 +30,9 @@ class Riffer::Providers::Base
30
30
  #--
31
31
  #: () -> String
32
32
  def self.semconv_provider_name
33
- class_name = name
34
33
  # Anonymous classes stay uncached: a class assigned to a constant
35
34
  # later must pick up its real name, not a frozen "unknown".
36
- return "unknown" unless class_name
35
+ class_name = name or return "unknown"
37
36
 
38
37
  @semconv_provider_name ||= Riffer::Helpers::Identifier.derive(class_name.split("::").last)
39
38
  end
@@ -8,10 +8,10 @@ class Riffer::Providers::FinishReason
8
8
  VALUES = %i[stop length tool_calls content_filter context_window malformed_output error other].freeze #: Array[Symbol]
9
9
 
10
10
  # The normalized reason.
11
- attr_reader :reason #: Symbol
11
+ attr_reader :reason #: Symbol # @dynamic reason
12
12
 
13
13
  # The provider's raw finish-reason value, when one exists on the wire.
14
- attr_reader :raw #: String?
14
+ attr_reader :raw #: String? # @dynamic raw
15
15
 
16
16
  # Raises Riffer::ArgumentError when +reason+ is outside VALUES.
17
17
  #--
@@ -25,7 +25,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
25
25
  end
26
26
 
27
27
  # Array of recorded method calls for assertions.
28
- attr_reader :calls #: Array[Hash[Symbol, untyped]]
28
+ attr_reader :calls #: Array[Hash[Symbol, untyped]] # @dynamic calls
29
29
 
30
30
  # +responses:+ pre-configures canned responses (same shape as
31
31
  # +#stub_response+) for standalone use; agent tests queue responses on
@@ -218,6 +218,8 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
218
218
  tool_info: {},
219
219
  } #: Hash[Symbol, untyped]
220
220
 
221
+ stream_completed = false
222
+
221
223
  stream = client.responses.stream(params)
222
224
  begin
223
225
  stream.each do |event|
@@ -247,6 +249,7 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
247
249
  when :"response.output_item.done"
248
250
  handle_output_item_done_web_search(event, yielder: yielder) if event.item&.type == :web_search_call
249
251
  when :"response.completed", :"response.incomplete", :"response.failed"
252
+ stream_completed = true
250
253
  handle_response_finished(event, state: current_state, yielder: yielder)
251
254
  end
252
255
  end
@@ -256,6 +259,11 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
256
259
  # socket leaks until GC. close is idempotent and a no-op after EOF.
257
260
  stream.close
258
261
  end
262
+
263
+ return if stream_completed
264
+
265
+ raise Riffer::IncompleteStreamError,
266
+ "OpenAI Responses stream ended without a response.completed, response.incomplete, or response.failed event"
259
267
  end
260
268
 
261
269
  #--
@@ -27,13 +27,9 @@ module Riffer::Providers::Repository
27
27
  #
28
28
  # Riffer::Providers::Repository.register(:jane) { MyApp::JaneProvider }
29
29
  #
30
- # Raises Riffer::ArgumentError when called without a block.
31
- #
32
30
  #--
33
31
  #: ((String | Symbol)) { () -> singleton(Riffer::Providers::Base) } -> void
34
32
  def register(identifier, &factory)
35
- raise Riffer::ArgumentError, "register requires a block returning a provider class" unless factory
36
-
37
33
  @registrations[identifier.to_sym] = factory
38
34
  @key_for = nil
39
35
  end
@@ -5,19 +5,19 @@
5
5
  # meaning for every provider.
6
6
  class Riffer::Providers::TokenUsage
7
7
  # Number of tokens entering the context window, including cache reads and writes.
8
- attr_reader :input_tokens #: Integer
8
+ attr_reader :input_tokens #: Integer # @dynamic input_tokens
9
9
 
10
10
  # Number of tokens generated by the model, including reasoning/thinking tokens.
11
- attr_reader :output_tokens #: Integer
11
+ attr_reader :output_tokens #: Integer # @dynamic output_tokens
12
12
 
13
13
  # Subset of +input_tokens+ written to the provider's prompt cache, when the provider reports it.
14
- attr_reader :cache_write_tokens #: Integer?
14
+ attr_reader :cache_write_tokens #: Integer? # @dynamic cache_write_tokens
15
15
 
16
16
  # Subset of +input_tokens+ read from the provider's prompt cache, when the provider reports it.
17
- attr_reader :cache_read_tokens #: Integer?
17
+ attr_reader :cache_read_tokens #: Integer? # @dynamic cache_read_tokens
18
18
 
19
19
  # Cost of the call, set when the model is priced. For observability, not billing.
20
- attr_reader :cost #: Float?
20
+ attr_reader :cost #: Float? # @dynamic cost
21
21
 
22
22
  #--
23
23
  #: (input_tokens: Integer, output_tokens: Integer, ?cache_write_tokens: Integer?, ?cache_read_tokens: Integer?, ?cost: Float?) -> void
@@ -7,7 +7,7 @@
7
7
  # for the rendered output.
8
8
  class Riffer::Skills::Adapter
9
9
  # The activation tool class for this adapter.
10
- attr_reader :skill_activate_tool #: singleton(Riffer::Tool)
10
+ attr_reader :skill_activate_tool #: singleton(Riffer::Tool) # @dynamic skill_activate_tool
11
11
 
12
12
  #--
13
13
  #: (skill_activate_tool: singleton(Riffer::Tool)) -> void
@@ -11,13 +11,13 @@ class Riffer::Skills::Context
11
11
  # @rbs @preactivated: Array[String]
12
12
 
13
13
  # Skill catalog indexed by name.
14
- attr_reader :skills #: Hash[String, Riffer::Skills::Frontmatter]
14
+ attr_reader :skills #: Hash[String, Riffer::Skills::Frontmatter] # @dynamic skills
15
15
 
16
16
  # The skill adapter used for this context.
17
- attr_reader :adapter #: Riffer::Skills::Adapter
17
+ attr_reader :adapter #: Riffer::Skills::Adapter # @dynamic adapter
18
18
 
19
19
  # Optional callback invoked when a skill is first activated.
20
- attr_accessor :on_activate #: (^(String) -> void)?
20
+ attr_accessor :on_activate #: (^(String) -> void)? # @dynamic on_activate, on_activate=
21
21
 
22
22
  #--
23
23
  #: (backend: Riffer::Skills::Backend, skills: Hash[String, Riffer::Skills::Frontmatter], adapter: Riffer::Skills::Adapter) -> void
@@ -106,10 +106,7 @@ class Riffer::Skills::Context
106
106
  #--
107
107
  #: (String) -> bool
108
108
  def model_invocable?(name)
109
- skill = skills[name]
110
- return false unless skill
111
-
112
- !skill.disable_model_invocation
109
+ skills.key?(name) && !skills.fetch(name).disable_model_invocation
113
110
  end
114
111
 
115
112
  # Returns whether any skill is available for the model to activate.
@@ -53,8 +53,7 @@ class Riffer::Skills::FilesystemBackend < Riffer::Skills::Backend
53
53
  def read_skill(name)
54
54
  list_skills unless @skills_cache
55
55
  cache = @skills_cache #: Hash[String, String]
56
- dir = cache[name]
57
- raise Riffer::ArgumentError, "Skill not found: '#{name}'" unless dir
56
+ dir = cache.fetch(name) { raise Riffer::ArgumentError, "Skill not found: '#{name}'" }
58
57
 
59
58
  _, body = Riffer::Skills::Frontmatter.parse(File.read(File.join(dir, SKILL_FILENAME)))
60
59
  body
@@ -13,19 +13,19 @@ class Riffer::Skills::Frontmatter
13
13
  MAX_DESCRIPTION_LENGTH = 1024 #: Integer
14
14
 
15
15
  # The skill name (1-64 chars, lowercase alphanumeric and hyphens).
16
- attr_reader :name #: String
16
+ attr_reader :name #: String # @dynamic name
17
17
 
18
18
  # The skill description (1-1024 chars).
19
- attr_reader :description #: String
19
+ attr_reader :description #: String # @dynamic description
20
20
 
21
21
  # Whether the skill opts out of model-driven activation. Hidden from the
22
22
  # catalog and rejected at model activation; still reachable via programmatic
23
23
  # activation.
24
- attr_reader :disable_model_invocation #: bool
24
+ attr_reader :disable_model_invocation #: bool # @dynamic disable_model_invocation
25
25
 
26
26
  # Metadata from the spec's +metadata+ field plus any unrecognized top-level
27
27
  # keys.
28
- attr_reader :metadata #: Hash[Symbol, untyped]
28
+ attr_reader :metadata #: Hash[Symbol, untyped] # @dynamic metadata
29
29
 
30
30
  # Parses a raw SKILL.md string into a +[Frontmatter, body]+ pair — public so
31
31
  # custom backends needn't reimplement parsing. Raises Riffer::ArgumentError
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- require "cgi"
4
+ require "cgi/escape"
5
5
 
6
6
  # Renders a skill catalog as XML for the system prompt, optimized for
7
7
  # Anthropic/Claude.
@@ -4,7 +4,7 @@
4
4
  # Base class for all streaming events. Subclasses must implement +to_h+.
5
5
  class Riffer::StreamEvents::Base
6
6
  # The message role (typically :assistant).
7
- attr_reader :role #: Symbol
7
+ attr_reader :role #: Symbol # @dynamic role
8
8
 
9
9
  #--
10
10
  #: (?role: Symbol) -> void
@@ -5,10 +5,10 @@
5
5
  # stream; no ordering guarantee relative to TokenUsageDone.
6
6
  class Riffer::StreamEvents::FinishReasonDone < Riffer::StreamEvents::Base
7
7
  # The normalized finish reason (see <tt>Riffer::Providers::FinishReason::VALUES</tt>).
8
- attr_reader :finish_reason #: Symbol
8
+ attr_reader :finish_reason #: Symbol # @dynamic finish_reason
9
9
 
10
10
  # The provider's raw finish-reason value, when one exists on the wire.
11
- attr_reader :raw_finish_reason #: String?
11
+ attr_reader :raw_finish_reason #: String? # @dynamic raw_finish_reason
12
12
 
13
13
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
14
14
  # normalized vocabulary.
@@ -4,7 +4,7 @@
4
4
  # Emitted when a guardrail transforms data during streaming.
5
5
  class Riffer::StreamEvents::GuardrailModification < Riffer::StreamEvents::Base
6
6
  # The modification record.
7
- attr_reader :modification #: Riffer::Guardrails::Modification
7
+ attr_reader :modification #: Riffer::Guardrails::Modification # @dynamic modification
8
8
 
9
9
  #--
10
10
  #: (Riffer::Guardrails::Modification, ?role: Symbol) -> void
@@ -4,7 +4,7 @@
4
4
  # Emitted when a guardrail blocks execution during streaming.
5
5
  class Riffer::StreamEvents::GuardrailTripwire < Riffer::StreamEvents::Base
6
6
  # The tripwire containing block details.
7
- attr_reader :tripwire #: Riffer::Guardrails::Tripwire
7
+ attr_reader :tripwire #: Riffer::Guardrails::Tripwire # @dynamic tripwire
8
8
 
9
9
  #--
10
10
  #: (Riffer::Guardrails::Tripwire, ?role: Symbol) -> void
@@ -5,11 +5,11 @@
5
5
  # +:riffer_interrupt+.
6
6
  class Riffer::StreamEvents::Interrupt < Riffer::StreamEvents::Base
7
7
  # The reason provided with the interrupt, if any.
8
- attr_reader :reason #: (String | Symbol)?
8
+ attr_reader :reason #: (String | Symbol)? # @dynamic reason
9
9
 
10
10
  # Call ids of tool_use blocks riffer filled with placeholder results when the
11
11
  # interrupt fired (only when history healing is on).
12
- attr_reader :healed_tool_call_ids #: Array[String]
12
+ attr_reader :healed_tool_call_ids #: Array[String] # @dynamic healed_tool_call_ids
13
13
 
14
14
  #--
15
15
  #: (?reason: (String | Symbol)?, ?healed_tool_call_ids: Array[String]) -> void