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
@@ -12,40 +12,22 @@ 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
20
- when :user
21
- files = (msg[:files] || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
22
- Riffer::Messages::User.new(msg[:content], id: msg[:id], files: files)
23
- when :assistant
24
- tool_calls = (msg[:tool_calls] || []).map do |tc|
25
- tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
26
- end
27
- Riffer::Messages::Assistant.new(
28
- msg[:content],
29
- id: msg[:id],
30
- tool_calls: tool_calls,
31
- structured_output: msg[:structured_output],
32
- finish_reason: msg[:finish_reason]&.to_sym,
33
- finish_reason_raw: msg[:finish_reason_raw],
34
- )
35
- when :system
36
- Riffer::Messages::System.new(msg[:content], id: msg[:id])
37
- when :tool
38
- Riffer::Messages::Tool.new(msg[:content], id: msg[:id], tool_call_id: msg[:tool_call_id], name: msg[:name])
39
- else
40
- raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
18
+ when :user then Riffer::Messages::User.from_hash(msg)
19
+ when :assistant then Riffer::Messages::Assistant.from_hash(msg)
20
+ when :system then Riffer::Messages::System.from_hash(msg)
21
+ when :tool then Riffer::Messages::Tool.from_hash(msg)
22
+ else raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
41
23
  end
42
24
  end
43
25
 
44
26
  # The message content.
45
- attr_reader :content #: String
27
+ attr_reader :content #: String # @dynamic content
46
28
 
47
29
  # The message id, or nil when +Riffer.config.message_id_strategy+ is +:none+.
48
- attr_reader :id #: String?
30
+ attr_reader :id #: String? # @dynamic id
49
31
 
50
32
  #--
51
33
  #: (String, ?id: String?) -> void
@@ -3,6 +3,16 @@
3
3
 
4
4
  # Represents a system message (instructions) in a conversation.
5
5
  class Riffer::Messages::System < Riffer::Messages::Base
6
+ # Builds a System message from a hash, or returns +msg+ unchanged when it is
7
+ # already a System message.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::System)) -> Riffer::Messages::System
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::System)
12
+
13
+ new(msg[:content], id: msg[:id])
14
+ end
15
+
6
16
  #--
7
17
  #: () -> Symbol
8
18
  def role
@@ -3,18 +3,35 @@
3
3
 
4
4
  # Represents a tool execution result in a conversation.
5
5
  class Riffer::Messages::Tool < Riffer::Messages::Base
6
+ # Builds a Tool message from a hash, or returns +msg+ unchanged when it is
7
+ # already a Tool message.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::Tool)) -> Riffer::Messages::Tool
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::Tool)
12
+
13
+ new(
14
+ msg[:content],
15
+ id: msg[:id],
16
+ tool_call_id: msg[:tool_call_id],
17
+ name: msg[:name],
18
+ error: msg[:error],
19
+ error_type: msg[:error_type]&.to_sym,
20
+ )
21
+ end
22
+
6
23
  # The ID of the tool call this result responds to.
7
- attr_reader :tool_call_id #: String
24
+ attr_reader :tool_call_id #: String # @dynamic tool_call_id
8
25
 
9
26
  # The name of the tool that was called.
10
- attr_reader :name #: String
27
+ attr_reader :name #: String # @dynamic name
11
28
 
12
29
  # The error message if the tool execution failed.
13
- attr_reader :error #: String?
30
+ attr_reader :error #: String? # @dynamic error
14
31
 
15
32
  # The type of error (:unknown_tool, :validation_error, :execution_error,
16
33
  # :timeout_error, :unhandled_error).
17
- attr_reader :error_type #: Symbol?
34
+ attr_reader :error_type #: Symbol? # @dynamic error_type
18
35
 
19
36
  #--
20
37
  #: (String, tool_call_id: String, name: String, ?id: String?, ?error: String?, ?error_type: Symbol?) -> void
@@ -6,7 +6,7 @@ require "uri"
6
6
 
7
7
  # Represents a file attachment (image or document) — from a URL (+from_url+) or
8
8
  # raw base64 data (+new+).
9
- class Riffer::Messages::FilePart
9
+ class Riffer::Messages::User::FilePart
10
10
  # @rbs @url_string: String?
11
11
  # @rbs @data: String?
12
12
  # @rbs @downloaded_data: String?
@@ -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.
@@ -62,26 +62,18 @@ class Riffer::Messages::FilePart
62
62
  # Creates a FilePart from a URL, detecting +media_type+ from the path
63
63
  # extension when omitted. Raises Riffer::ArgumentError if it can't be detected.
64
64
  #--
65
- #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::FilePart
65
+ #: (String, ?media_type: String?, ?filename: String?, ?sha256: String?) -> Riffer::Messages::User::FilePart
66
66
  def self.from_url(url, media_type: nil, filename: nil, sha256: nil)
67
- unless media_type
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
71
- end
72
-
73
- new(url: url, media_type: media_type, filename: filename, sha256: sha256)
67
+ new(url: url, media_type: media_type || detect_media_type(url), filename: filename, sha256: sha256)
74
68
  end
75
69
 
76
70
  # Builds a FilePart from a +{url:, media_type:}+ or +{data:, media_type:}+ hash,
77
71
  # or returns +file+ unchanged when it is already a FilePart. Raises
78
72
  # Riffer::ArgumentError on an invalid hash.
79
73
  #--
80
- #: ((Hash[Symbol, untyped] | Riffer::Messages::FilePart)) -> Riffer::Messages::FilePart
74
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::User::FilePart)) -> Riffer::Messages::User::FilePart
81
75
  def self.from_hash(file)
82
- return file if file.is_a?(Riffer::Messages::FilePart)
83
-
84
- raise Riffer::ArgumentError, "File must be a Hash or FilePart object, got #{file.class}" unless file.is_a?(Hash)
76
+ return file if file.is_a?(Riffer::Messages::User::FilePart)
85
77
 
86
78
  url = file[:url]
87
79
  data = file[:data]
@@ -90,7 +82,7 @@ class Riffer::Messages::FilePart
90
82
  sha256 = file[:sha256]
91
83
 
92
84
  if url
93
- from_url(url, media_type: media_type, filename: filename, sha256: sha256)
85
+ new(url: url, data: data, media_type: media_type || detect_media_type(url), filename: filename, sha256: sha256)
94
86
  elsif data && media_type
95
87
  new(data: data, media_type: media_type, filename: filename, sha256: sha256)
96
88
  else
@@ -98,6 +90,15 @@ class Riffer::Messages::FilePart
98
90
  end
99
91
  end
100
92
 
93
+ # Raises Riffer::ArgumentError when the URL's extension isn't a known media type.
94
+ #--
95
+ #: (String) -> String
96
+ def self.detect_media_type(url)
97
+ ext = ::File.extname(URI.parse(url).path.to_s).downcase
98
+ MEDIA_TYPES.fetch(ext) { raise Riffer::ArgumentError, "Cannot detect media type from URL; provide media_type explicitly" }
99
+ end
100
+ private_class_method :detect_media_type
101
+
101
102
  # The base64-encoded contents - caller-supplied, or filled in by the file
102
103
  # resolver after a download. Nil for a URL source riffer hasn't fetched.
103
104
  #--
@@ -3,11 +3,22 @@
3
3
 
4
4
  # Represents a user message in a conversation.
5
5
  class Riffer::Messages::User < Riffer::Messages::Base
6
+ # Builds a User message from a hash, or returns +msg+ unchanged when it is
7
+ # already a User message. Raises Riffer::ArgumentError on an invalid file.
8
+ #--
9
+ #: ((Hash[Symbol, untyped] | Riffer::Messages::User)) -> Riffer::Messages::User
10
+ def self.from_hash(msg)
11
+ return msg if msg.is_a?(Riffer::Messages::User)
12
+
13
+ files = (msg[:files] || []).map { |f| Riffer::Messages::User::FilePart.from_hash(f) }
14
+ new(msg[:content], id: msg[:id], files: files)
15
+ end
16
+
6
17
  # File attachments for this message.
7
- attr_reader :files #: Array[Riffer::Messages::FilePart]
18
+ attr_reader :files #: Array[Riffer::Messages::User::FilePart] # @dynamic files
8
19
 
9
20
  #--
10
- #: (String, ?id: String?, ?files: Array[Riffer::Messages::FilePart]) -> void
21
+ #: (String, ?id: String?, ?files: Array[Riffer::Messages::User::FilePart]) -> void
11
22
  def initialize(content, id: nil, files: [])
12
23
  super(content, id: id)
13
24
  @files = files
@@ -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.
@@ -181,6 +181,18 @@ class Riffer::Params::Param
181
181
 
182
182
  private
183
183
 
184
+ # +dup+ would leave the copy sharing this one's nested Params, enum list and
185
+ # default, so defining a parameter or editing either value on one would reach
186
+ # the other.
187
+ #--
188
+ #: (Riffer::Params::Param) -> void
189
+ def initialize_copy(source)
190
+ super
191
+ @nested_params = source.nested_params&.dup
192
+ @enum = Riffer::Helpers::DeepDup.call(source.enum)
193
+ @default = Riffer::Helpers::DeepDup.call(source.default)
194
+ end
195
+
184
196
  #--
185
197
  #: () -> void
186
198
  def validate_strict_shape!
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
@@ -151,6 +151,15 @@ class Riffer::Params
151
151
 
152
152
  private
153
153
 
154
+ # +dup+ would leave the copy sharing this one's parameters, so a caller
155
+ # defining a parameter on either would reach the other.
156
+ #--
157
+ #: (Riffer::Params) -> void
158
+ def initialize_copy(source)
159
+ super
160
+ @parameters = source.parameters.map(&:dup)
161
+ end
162
+
154
163
  #--
155
164
  #: (Module, Module?) ?{ (Riffer::Params) [self: Riffer::Params] -> void } -> Riffer::Params?
156
165
  def build_nested(type, of, &block)
@@ -47,7 +47,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
47
47
  end
48
48
 
49
49
  #--
50
- #: (Riffer::Messages::FilePart) -> Symbol
50
+ #: (Riffer::Messages::User::FilePart) -> Symbol
51
51
  def file_delivery(file)
52
52
  file.url&.start_with?("s3://") ? :url : :bytes
53
53
  end
@@ -98,8 +98,9 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
98
98
 
99
99
  # requestMetadata is a flat String=>String map used to filter invocation
100
100
  # logs; every tag (including the reserved user_id) rides along, since
101
- # Converse has no dedicated end-user field.
102
- params[:request_metadata] = tags unless tags.empty?
101
+ # Converse has no dedicated end-user field. Merged over any request_metadata
102
+ # set in model_options; a tag wins on a shared key.
103
+ params[:request_metadata] = (params[:request_metadata] || {}).merge(tags) unless tags.empty?
103
104
 
104
105
  if tools && !tools.empty?
105
106
  params[:tool_config] = {
@@ -154,7 +155,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
154
155
  #: (untyped) -> Hash[Symbol, untyped]
155
156
  def build_cache_point(cache_control)
156
157
  point = { type: "default" } #: Hash[Symbol, untyped]
157
- ttl = cache_control.is_a?(Hash) ? cache_control[:ttl] : nil
158
+ ttl = cache_control[:ttl] if cache_control.is_a?(Hash)
158
159
  point[:ttl] = ttl if ttl
159
160
  point
160
161
  end
@@ -252,6 +253,8 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
252
253
  tool_call: nil,
253
254
  } #: Hash[Symbol, untyped]
254
255
 
256
+ stream_completed = false
257
+
255
258
  client.converse_stream(**params) do |stream|
256
259
  stream.on_event do |event|
257
260
  case event
@@ -264,20 +267,45 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
264
267
  handle_content_block_stop_text_delta(event, state: current_state, yielder: yielder) if current_state[:text]
265
268
  handle_content_block_stop_tool_use(event, state: current_state, yielder: yielder) if current_state[:tool_call]
266
269
  when Aws::BedrockRuntime::Types::MessageStopEvent
270
+ stream_completed = true
267
271
  yield_finish_reason(yielder, build_finish_reason(event.stop_reason))
268
272
  when Aws::BedrockRuntime::Types::ConverseStreamMetadataEvent
269
273
  handle_metadata_usage(event, state: current_state, yielder: yielder) if event.usage
274
+ when Aws::Errors::EventError
275
+ # The SDK turns an event-stream +:message-type: error+ frame into an
276
+ # EventError instance and hands it to this block as an event; it is
277
+ # never raised. Re-raise it here so the failure surfaces instead of
278
+ # truncating the stream.
279
+ raise_stream_event_error!(event)
270
280
  else
271
281
  raise_if_stream_exception!(event)
272
282
  end
273
283
  end
274
284
  end
285
+
286
+ return if stream_completed
287
+
288
+ raise Riffer::IncompleteStreamError, "Bedrock ConverseStream ended without a messageStop event"
289
+ end
290
+
291
+ # Re-raises an +Aws::Errors::EventError+ event with a message built from its
292
+ # error code and message. The SDK's own +#message+ is just the class name,
293
+ # so without this the failure would be unreadable.
294
+ #--
295
+ #: (untyped) -> void
296
+ def raise_stream_event_error!(event)
297
+ details = [event.error_code, event.error_message].compact.join(": ")
298
+ details = "Bedrock ConverseStream error event" if details.empty?
299
+
300
+ raise event.exception(details)
275
301
  end
276
302
 
277
303
  # Re-raises a Bedrock stream-exception event as the matching
278
304
  # +Aws::BedrockRuntime::Errors+ class. ConverseStream delivers API errors on
279
305
  # the same channel as content, so without this a mid-stream failure would
280
- # silently end the stream with no content.
306
+ # silently end the stream with no content. Non-exception events that we do
307
+ # not consume (including the SDK's +:unknown_event+ struct) are ignored for
308
+ # forward compatibility.
281
309
  #--
282
310
  #: (untyped) -> void
283
311
  def raise_if_stream_exception!(event)
@@ -422,7 +450,7 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
422
450
  end
423
451
 
424
452
  #--
425
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
453
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
426
454
  def convert_file_part_to_bedrock_format(file)
427
455
  format = bedrock_format(file.media_type)
428
456
 
@@ -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
  #--
@@ -331,7 +338,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
331
338
  #--
332
339
  #: (untyped, state: Hash[Symbol, untyped], yielder: Riffer::Providers::_EventSink) -> void
333
340
  def handle_content_block_stop_thinking(_event, state:, yielder:)
334
- yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning])
341
+ yield_reasoning_done(yielder, state[:reasoning])
335
342
  state[:reasoning] = nil
336
343
  end
337
344
 
@@ -437,7 +444,7 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
437
444
  end
438
445
 
439
446
  #--
440
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
447
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
441
448
  def convert_file_part_to_anthropic_format(file)
442
449
  type = file.image? ? "image" : "document"
443
450
 
@@ -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
@@ -41,7 +40,7 @@ class Riffer::Providers::Base
41
40
  # Generates text using the provider.
42
41
  #
43
42
  #--
44
- #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Riffer::Messages::Assistant
43
+ #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Riffer::Messages::Assistant
45
44
  def generate_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
46
45
  validate_input!(prompt: prompt, system: system, messages: messages)
47
46
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
@@ -57,6 +56,7 @@ class Riffer::Providers::Base
57
56
 
58
57
  content = extract_content(response)
59
58
  tool_calls = extract_tool_calls(response)
59
+ reasoning = extract_reasoning(response)
60
60
  token_usage = extract_token_usage(response)
61
61
  finish_reason = extract_finish_reason(response)
62
62
  structured_output = parse_structured_output(content) if options[:structured_output] && tool_calls.empty?
@@ -68,6 +68,7 @@ class Riffer::Providers::Base
68
68
  Riffer::Messages::Assistant.new(
69
69
  content,
70
70
  tool_calls: tool_calls,
71
+ reasoning: reasoning,
71
72
  token_usage: token_usage,
72
73
  structured_output: structured_output,
73
74
  finish_reason: finish_reason&.reason,
@@ -79,7 +80,7 @@ class Riffer::Providers::Base
79
80
  # Streams text from the provider.
80
81
  #
81
82
  #--
82
- #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
83
+ #: (?prompt: String?, ?system: String?, ?messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?model: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?, **untyped) -> Enumerator[Riffer::StreamEvents::Base, void]
83
84
  def stream_text(prompt: nil, system: nil, messages: nil, model: nil, files: nil, **options)
84
85
  validate_input!(prompt: prompt, system: system, messages: messages)
85
86
  @current_tools = options[:tools] || [] #: Array[singleton(Riffer::Tool)]
@@ -106,7 +107,7 @@ class Riffer::Providers::Base
106
107
  end
107
108
 
108
109
  #--
109
- #: (Riffer::Messages::FilePart) -> Symbol
110
+ #: (Riffer::Messages::User::FilePart) -> Symbol
110
111
  def file_delivery(_file)
111
112
  :url
112
113
  end
@@ -225,6 +226,14 @@ class Riffer::Providers::Base
225
226
  nil
226
227
  end
227
228
 
229
+ # Defaults to no parts rather than raising — reasoning parts are optional, so
230
+ # providers that don't expose replayable reasoning stay valid.
231
+ #--
232
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
233
+ def extract_reasoning(_response)
234
+ []
235
+ end
236
+
228
237
  #--
229
238
  #: (untyped) -> String
230
239
  def extract_content(response)
@@ -349,6 +358,15 @@ class Riffer::Providers::Base
349
358
  Riffer.config.tracing.capture_messages && span.recording?
350
359
  end
351
360
 
361
+ # Wraps reasoning text that an adapter cannot yet replay in a +:text+ part
362
+ # with no +format+, so it persists for display but is never sent back.
363
+ #--
364
+ #: (Riffer::Providers::_EventSink, String) -> void
365
+ def yield_reasoning_done(yielder, text)
366
+ part = Riffer::Messages::Assistant::ReasoningPart.new(type: :text, text: text)
367
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
368
+ end
369
+
352
370
  #--
353
371
  #: (Riffer::Providers::_EventSink, Riffer::Providers::FinishReason?) -> void
354
372
  def yield_finish_reason(yielder, finish_reason)
@@ -369,11 +387,11 @@ class Riffer::Providers::Base
369
387
  end
370
388
 
371
389
  #--
372
- #: ((String | Hash[String, untyped])?) -> Hash[String, untyped]
390
+ #: (String) -> Hash[String, untyped]
373
391
  def parse_tool_arguments(arguments)
374
- return {} if arguments.nil? || arguments.empty?
392
+ return {} if arguments.empty?
375
393
 
376
- arguments.is_a?(String) ? JSON.parse(arguments) : arguments
394
+ JSON.parse(arguments)
377
395
  end
378
396
 
379
397
  #--
@@ -398,7 +416,7 @@ class Riffer::Providers::Base
398
416
  end
399
417
 
400
418
  #--
401
- #: (prompt: String?, system: String?, messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Array[Riffer::Messages::Base]
419
+ #: (prompt: String?, system: String?, messages: Array[Hash[Symbol, untyped] | Riffer::Messages::Base]?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::User::FilePart]?) -> Array[Riffer::Messages::Base]
402
420
  def normalize_messages(prompt:, system:, messages:, files: nil)
403
421
  if messages && files && !files.empty?
404
422
  raise Riffer::ArgumentError, "cannot provide both files and messages; attach files to individual messages instead"
@@ -408,7 +426,7 @@ class Riffer::Providers::Base
408
426
 
409
427
  result = [] #: Array[Riffer::Messages::Base]
410
428
  result << Riffer::Messages::System.new(system) if system
411
- file_parts = (files || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
429
+ file_parts = (files || []).map { |f| Riffer::Messages::User::FilePart.from_hash(f) }
412
430
  prompt_text = prompt #: String
413
431
  result << Riffer::Messages::User.new(prompt_text, files: file_parts)
414
432
  result
@@ -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
  #--
@@ -37,7 +37,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
37
37
  end
38
38
 
39
39
  #--
40
- #: (Riffer::Messages::FilePart) -> Symbol
40
+ #: (Riffer::Messages::User::FilePart) -> Symbol
41
41
  def file_delivery(_file)
42
42
  :base64
43
43
  end
@@ -287,7 +287,7 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
287
287
  end
288
288
 
289
289
  #--
290
- #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
290
+ #: (Riffer::Messages::User::FilePart) -> Hash[Symbol, untyped]
291
291
  def convert_file_part_to_gemini_format(file)
292
292
  { inlineData: { mimeType: file.media_type, data: file.data } }
293
293
  end
@@ -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
@@ -54,15 +54,17 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
54
54
  # provider.stub_response("Final response",
55
55
  # token_usage: Riffer::Providers::TokenUsage.new(input_tokens: 10, output_tokens: 5))
56
56
  # provider.stub_response("Truncated...", finish_reason: :length)
57
+ # provider.stub_response("Answer", reasoning: [{type: :text, text: "Thinking...", format: "mock-v1"}])
57
58
  #
58
59
  #--
59
- #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?) -> void
60
- def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil)
60
+ #: (String, ?tool_calls: Array[Hash[Symbol, untyped]], ?token_usage: Riffer::Providers::TokenUsage?, ?finish_reason: Symbol?, ?reasoning: Array[Hash[Symbol, untyped] | Riffer::Messages::Assistant::ReasoningPart]) -> void
61
+ def stub_response(content, tool_calls: [], token_usage: nil, finish_reason: nil, reasoning: [])
61
62
  @stubbed_responses << normalize_response(
62
63
  content: content,
63
64
  tool_calls: tool_calls,
64
65
  token_usage: token_usage,
65
66
  finish_reason: finish_reason,
67
+ reasoning: reasoning,
66
68
  )
67
69
  end
68
70
 
@@ -92,6 +94,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
92
94
  role: response[:role] || "assistant",
93
95
  content: response[:content] || "",
94
96
  tool_calls: formatted_tool_calls,
97
+ reasoning: (response[:reasoning] || []).map { |part| Riffer::Messages::Assistant::ReasoningPart.from_hash(part) },
95
98
  token_usage: response[:token_usage],
96
99
  finish_reason: response[:finish_reason] || (formatted_tool_calls.empty? ? :stop : :tool_calls),
97
100
  }
@@ -129,6 +132,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
129
132
  reason ? Riffer::Providers::FinishReason.new(reason: reason) : nil
130
133
  end
131
134
 
135
+ #--
136
+ #: (untyped) -> Array[Riffer::Messages::Assistant::ReasoningPart]
137
+ def extract_reasoning(response)
138
+ response[:reasoning] || []
139
+ end
140
+
132
141
  #--
133
142
  #: (untyped) -> String
134
143
  def extract_content(response)
@@ -149,6 +158,7 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
149
158
  tool_calls = response[:tool_calls] || []
150
159
  token_usage = response[:token_usage]
151
160
  web_search = response[:web_search]
161
+ reasoning = response[:reasoning] || []
152
162
 
153
163
  if web_search
154
164
  yielder << Riffer::StreamEvents::WebSearchStatus.new("in_progress")
@@ -161,6 +171,12 @@ class Riffer::Providers::Mock < Riffer::Providers::Base
161
171
  )
162
172
  end
163
173
 
174
+ reasoning.each do |part|
175
+ text = part.text
176
+ yielder << Riffer::StreamEvents::ReasoningDelta.new(text) if text
177
+ yielder << Riffer::StreamEvents::ReasoningDone.new(part)
178
+ end
179
+
164
180
  unless full_content.empty?
165
181
  content_parts = full_content.split(". ").map { |part| part + (part.end_with?(".") ? "" : ".") }
166
182
  content_parts.each do |part|