riffer 0.45.0 → 0.46.1

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.
@@ -1,30 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
  # rbs_inline: enabled
3
3
 
4
- # Wraps an agent generation response. When a guardrail blocks execution,
5
- # +content+ is empty and +tripwire+ carries the block details.
4
+ # Wraps an agent generation response. +outcome+ says how the run ended; when a
5
+ # guardrail blocks execution, +content+ is empty and +tripwire+ carries the
6
+ # block details.
6
7
  #
7
8
  # response = agent.generate("Hello")
8
- # if response.blocked?
9
- # puts "Blocked: #{response.tripwire.reason}"
10
- # else
9
+ # if response.outcome.success?
11
10
  # puts response.content
11
+ # else
12
+ # puts "#{response.outcome.reason}: #{response.outcome.detail}"
12
13
  # end
13
14
  class Riffer::Agent::Response
14
- # @rbs @interrupted: bool
15
-
16
15
  # The response content.
17
16
  attr_reader :content #: String
18
17
 
18
+ # How the run ended.
19
+ attr_reader :outcome #: Riffer::Agent::Outcome
20
+
19
21
  # The tripwire if execution was blocked.
20
22
  attr_reader :tripwire #: Riffer::Guardrails::Tripwire?
21
23
 
22
24
  # The modifications made by guardrails during processing.
23
25
  attr_reader :modifications #: Array[Riffer::Guardrails::Modification]
24
26
 
25
- # The reason provided with the interrupt, if any.
26
- attr_reader :interrupt_reason #: (String | Symbol)?
27
-
28
27
  # The parsed structured output, if structured output was configured.
29
28
  attr_reader :structured_output #: Hash[Symbol, untyped]?
30
29
 
@@ -45,10 +44,9 @@ class Riffer::Agent::Response
45
44
  #--
46
45
  #: (
47
46
  # String,
47
+ # outcome: Riffer::Agent::Outcome,
48
48
  # ?tripwire: Riffer::Guardrails::Tripwire?,
49
49
  # ?modifications: Array[Riffer::Guardrails::Modification],
50
- # ?interrupted: bool,
51
- # ?interrupt_reason: (String | Symbol)?,
52
50
  # ?structured_output: Hash[Symbol, untyped]?,
53
51
  # ?messages: Array[Riffer::Messages::Base],
54
52
  # ?healed_tool_call_ids: Array[String],
@@ -57,10 +55,9 @@ class Riffer::Agent::Response
57
55
  # ) -> void
58
56
  def initialize(
59
57
  content,
58
+ outcome:,
60
59
  tripwire: nil,
61
60
  modifications: [],
62
- interrupted: false,
63
- interrupt_reason: nil,
64
61
  structured_output: nil,
65
62
  messages: [],
66
63
  healed_tool_call_ids: [],
@@ -68,10 +65,9 @@ class Riffer::Agent::Response
68
65
  steps: 0
69
66
  )
70
67
  @content = content
68
+ @outcome = outcome
71
69
  @tripwire = tripwire
72
70
  @modifications = modifications
73
- @interrupted = interrupted
74
- @interrupt_reason = interrupt_reason
75
71
  @structured_output = structured_output
76
72
  @messages = messages
77
73
  @healed_tool_call_ids = healed_tool_call_ids
@@ -79,14 +75,6 @@ class Riffer::Agent::Response
79
75
  @steps = steps
80
76
  end
81
77
 
82
- # Returns true if the response was blocked by a guardrail.
83
- #
84
- #--
85
- #: () -> bool
86
- def blocked?
87
- !tripwire.nil?
88
- end
89
-
90
78
  # Returns true if any guardrail modified data during processing.
91
79
  #
92
80
  #--
@@ -94,13 +82,4 @@ class Riffer::Agent::Response
94
82
  def modified?
95
83
  modifications.any?
96
84
  end
97
-
98
- # Returns true if the agent loop was interrupted by a callback
99
- # via <tt>throw :riffer_interrupt</tt>.
100
- #
101
- #--
102
- #: () -> bool
103
- def interrupted?
104
- @interrupted
105
- end
106
85
  end
@@ -20,7 +20,7 @@ module Riffer::Agent::Run
20
20
  # for prompt/files semantics.
21
21
  #
22
22
  #--
23
- #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
23
+ #: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
24
24
  def stream(agent:, prompt: nil, files: nil, tags: {})
25
25
  append_user_message(agent, prompt, files: files)
26
26
  # The enumerator body runs in its own fiber, where the fiber-local OTEL
@@ -155,6 +155,7 @@ module Riffer::Agent::Run
155
155
  accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
156
156
  accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
157
157
  accumulated_finish_reason = nil #: Symbol?
158
+ accumulated_finish_reason_raw = nil #: String?
158
159
 
159
160
  call_llm_stream(agent, tags).each do |event|
160
161
  stream_yielder << event
@@ -178,6 +179,7 @@ module Riffer::Agent::Run
178
179
  accumulated_token_usage = event.token_usage
179
180
  when Riffer::StreamEvents::FinishReasonDone
180
181
  accumulated_finish_reason = event.finish_reason
182
+ accumulated_finish_reason_raw = event.raw_finish_reason
181
183
  end
182
184
  end
183
185
 
@@ -186,6 +188,7 @@ module Riffer::Agent::Run
186
188
  tool_calls: accumulated_tool_calls,
187
189
  token_usage: accumulated_token_usage,
188
190
  finish_reason: accumulated_finish_reason,
191
+ finish_reason_raw: accumulated_finish_reason_raw,
189
192
  )
190
193
  end
191
194
 
@@ -207,6 +210,7 @@ module Riffer::Agent::Run
207
210
  build_response(
208
211
  agent,
209
212
  "",
213
+ outcome: Riffer::Agent::Outcome.new(reason: :guardrail_blocked, detail: tripwire.reason),
210
214
  tripwire: tripwire,
211
215
  modifications: all_modifications,
212
216
  token_usage: token_usage,
@@ -215,18 +219,41 @@ module Riffer::Agent::Run
215
219
  end
216
220
 
217
221
  #--
218
- #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], **untyped) -> Riffer::Agent::Response
219
- def final_response(agent, all_modifications, **extra)
220
- response = agent.session.final_assistant_message
222
+ #: (Riffer::Agent, Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, **untyped) -> Riffer::Agent::Response
223
+ def final_response(agent, all_modifications, interrupted: false, interrupt_reason: nil, **extra)
224
+ message = agent.session.final_assistant_message
225
+ result = agent.structured_output && structured_output_result(agent, message)
221
226
  build_response(
222
227
  agent,
223
- response&.content || "",
228
+ message&.content || "",
229
+ outcome: final_outcome(message, result, interrupted: interrupted, interrupt_reason: interrupt_reason),
224
230
  modifications: all_modifications,
225
- structured_output: validate_structured_output(agent, response),
231
+ structured_output: result&.object,
226
232
  **extra,
227
233
  )
228
234
  end
229
235
 
236
+ # Checked in the order things happened. The loop being stopped (max_steps or
237
+ # an interrupt) beats the provider's finish reason, which beats riffer's own
238
+ # validation of the content. A truncated response that also fails the schema
239
+ # therefore reports :length, not :invalid_structured_output.
240
+ #--
241
+ #: (Riffer::Messages::Assistant?, Riffer::Agent::StructuredOutput::Result?, interrupted: bool, interrupt_reason: (String | Symbol)?) -> Riffer::Agent::Outcome
242
+ def final_outcome(message, result, interrupted:, interrupt_reason:)
243
+ finish_reason = message&.finish_reason
244
+ if interrupted && interrupt_reason == Riffer::Agent::INTERRUPT_MAX_STEPS
245
+ Riffer::Agent::Outcome.new(reason: :max_steps)
246
+ elsif interrupted
247
+ Riffer::Agent::Outcome.new(reason: :interrupted, detail: interrupt_reason&.to_s)
248
+ elsif finish_reason && !Riffer::Agent::Outcome::NORMAL_FINISH_REASONS.include?(finish_reason)
249
+ Riffer::Agent::Outcome.new(reason: finish_reason, detail: message&.finish_reason_raw)
250
+ elsif result&.failure?
251
+ Riffer::Agent::Outcome.new(reason: :invalid_structured_output, detail: result.error)
252
+ else
253
+ Riffer::Agent::Outcome.new(reason: :completed)
254
+ end
255
+ end
256
+
230
257
  #--
231
258
  #: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
232
259
  def call_llm(agent, tags = {})
@@ -327,11 +354,11 @@ module Riffer::Agent::Run
327
354
  end
328
355
 
329
356
  #--
330
- #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Hash[Symbol, untyped]?
331
- def validate_structured_output(agent, response)
332
- return unless response&.structured_output? && agent.structured_output
357
+ #: (Riffer::Agent, Riffer::Messages::Assistant?) -> Riffer::Agent::StructuredOutput::Result?
358
+ def structured_output_result(agent, message)
359
+ return unless message
333
360
 
334
- agent.structured_output.parse_and_validate(response.content).object
361
+ agent.structured_output&.parse_and_validate(message.content)
335
362
  end
336
363
 
337
364
  #--
@@ -358,10 +385,9 @@ module Riffer::Agent::Run
358
385
  #: (
359
386
  # Riffer::Agent,
360
387
  # String,
388
+ # outcome: Riffer::Agent::Outcome,
361
389
  # ?tripwire: Riffer::Guardrails::Tripwire?,
362
390
  # ?modifications: Array[Riffer::Guardrails::Modification],
363
- # ?interrupted: bool,
364
- # ?interrupt_reason: (String | Symbol)?,
365
391
  # ?structured_output: Hash[Symbol, untyped]?,
366
392
  # ?healed_tool_call_ids: Array[String],
367
393
  # ?token_usage: Riffer::Providers::TokenUsage?,
@@ -370,10 +396,9 @@ module Riffer::Agent::Run
370
396
  def build_response(
371
397
  agent,
372
398
  content,
399
+ outcome:,
373
400
  tripwire: nil,
374
401
  modifications: [],
375
- interrupted: false,
376
- interrupt_reason: nil,
377
402
  structured_output: nil,
378
403
  healed_tool_call_ids: [],
379
404
  token_usage: nil,
@@ -382,10 +407,9 @@ module Riffer::Agent::Run
382
407
  messages = agent.session.messages
383
408
  Riffer::Agent::Response.new(
384
409
  content,
410
+ outcome: outcome,
385
411
  tripwire: tripwire,
386
412
  modifications: modifications,
387
- interrupted: interrupted,
388
- interrupt_reason: interrupt_reason,
389
413
  structured_output: structured_output,
390
414
  messages: messages.frozen? ? messages : messages.dup.freeze,
391
415
  healed_tool_call_ids: healed_tool_call_ids,
@@ -460,7 +484,12 @@ module Riffer::Agent::Run
460
484
  span.set_attribute("riffer.steps", response.steps)
461
485
  Riffer::Tracing.record_usage(span, response.token_usage)
462
486
 
463
- span.set_attribute("riffer.interrupt.reason", response.interrupt_reason.to_s) if response.interrupt_reason
487
+ outcome = response.outcome
488
+ span.set_attribute("riffer.outcome.reason", outcome.reason.to_s)
489
+ detail = outcome.detail
490
+ span.set_attribute("riffer.outcome.detail", detail) if detail
491
+ interrupt_reason = interrupt_reason_attribute(outcome)
492
+ span.set_attribute("riffer.interrupt.reason", interrupt_reason) if interrupt_reason
464
493
 
465
494
  tripwire = response.tripwire
466
495
  return unless tripwire
@@ -470,4 +499,13 @@ module Riffer::Agent::Run
470
499
  span.set_attribute("riffer.tripwire.reason", tripwire.reason)
471
500
  span.set_attribute("riffer.tripwire.phase", tripwire.phase.to_s)
472
501
  end
502
+
503
+ #--
504
+ #: (Riffer::Agent::Outcome) -> String?
505
+ def interrupt_reason_attribute(outcome)
506
+ case outcome.reason
507
+ when :max_steps then Riffer::Agent::INTERRUPT_MAX_STEPS.to_s
508
+ when :interrupted then outcome.detail
509
+ end
510
+ end
473
511
  end
@@ -203,6 +203,8 @@ class Riffer::Agent::Session
203
203
  tool_calls: attrs.fetch(:tool_calls, old.tool_calls),
204
204
  token_usage: attrs.fetch(:token_usage, old.token_usage),
205
205
  structured_output: attrs.fetch(:structured_output, old.structured_output),
206
+ finish_reason: attrs.fetch(:finish_reason, old.finish_reason),
207
+ finish_reason_raw: attrs.fetch(:finish_reason_raw, old.finish_reason_raw),
206
208
  )
207
209
  when Riffer::Messages::Tool
208
210
  Riffer::Messages::Tool.new(
data/lib/riffer/agent.rb CHANGED
@@ -155,7 +155,7 @@ class Riffer::Agent
155
155
 
156
156
  # Streams a response using a new agent instance.
157
157
  #--
158
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
158
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
159
159
  def self.stream(prompt = nil, files: nil, context: nil, tags: {})
160
160
  new(context: context).stream(prompt, files: files, tags: tags)
161
161
  end
@@ -304,13 +304,14 @@ class Riffer::Agent
304
304
  Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files, tags: tags)
305
305
  end
306
306
 
307
- # Streams a response from the agent, returning an +Enumerator+ of
308
- # +Riffer::StreamEvents+. See +#generate+ for prompt/files/tags semantics.
307
+ # Streams a response from the agent as an +Enumerator+ of
308
+ # +Riffer::StreamEvents+ whose block-form +each+ returns the final
309
+ # Riffer::Agent::Response. See +#generate+ for prompt/files/tags semantics.
309
310
  #
310
311
  # Raises Riffer::ArgumentError if structured output is configured.
311
312
  #
312
313
  #--
313
- #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
314
+ #: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, Riffer::Agent::Response]
314
315
  def stream(prompt = nil, files: nil, tags: {})
315
316
  if @structured_output
316
317
  raise Riffer::ArgumentError,
@@ -19,11 +19,31 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
19
19
  # <tt>Riffer::Providers::FinishReason::VALUES</tt>).
20
20
  attr_reader :finish_reason #: Symbol?
21
21
 
22
+ # The provider's raw finish-reason value behind +finish_reason+, when one
23
+ # exists on the wire.
24
+ attr_reader :finish_reason_raw #: String?
25
+
22
26
  # Raises Riffer::ArgumentError when +finish_reason+ is outside the
23
27
  # normalized vocabulary.
24
28
  #--
25
- #: (String, ?id: String?, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?token_usage: Riffer::Providers::TokenUsage?, ?structured_output: Hash[Symbol, untyped]?, ?finish_reason: Symbol?) -> void
26
- def initialize(content, id: nil, tool_calls: [], token_usage: nil, structured_output: nil, finish_reason: nil)
29
+ #: (
30
+ # String,
31
+ # ?id: String?,
32
+ # ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall],
33
+ # ?token_usage: Riffer::Providers::TokenUsage?,
34
+ # ?structured_output: Hash[Symbol, untyped]?,
35
+ # ?finish_reason: Symbol?,
36
+ # ?finish_reason_raw: String?
37
+ # ) -> void
38
+ def initialize(
39
+ content,
40
+ id: nil,
41
+ tool_calls: [],
42
+ token_usage: nil,
43
+ structured_output: nil,
44
+ finish_reason: nil,
45
+ finish_reason_raw: nil
46
+ )
27
47
  if finish_reason && !Riffer::Providers::FinishReason::VALUES.include?(finish_reason)
28
48
  values = Riffer::Providers::FinishReason::VALUES.inspect
29
49
  raise Riffer::ArgumentError, "finish_reason must be one of #{values}, got #{finish_reason.inspect}"
@@ -34,6 +54,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
34
54
  @token_usage = token_usage
35
55
  @structured_output = structured_output
36
56
  @finish_reason = finish_reason
57
+ @finish_reason_raw = finish_reason_raw
37
58
  end
38
59
 
39
60
  #--
@@ -71,6 +92,7 @@ class Riffer::Messages::Assistant < Riffer::Messages::Base
71
92
  hash[:token_usage] = token_usage.to_h if token_usage
72
93
  hash[:structured_output] = structured_output if structured_output?
73
94
  hash[:finish_reason] = finish_reason if finish_reason
95
+ hash[:finish_reason_raw] = finish_reason_raw if finish_reason_raw
74
96
  hash
75
97
  end
76
98
  end
@@ -14,38 +14,30 @@ class Riffer::Messages::Base
14
14
 
15
15
  raise Riffer::ArgumentError, "Message must be a Hash or Message object, got #{msg.class}" unless msg.is_a?(Hash)
16
16
 
17
- role = msg[:role]
18
- content = msg[:content]
17
+ raise Riffer::ArgumentError, "Message hash must include a 'role' key" if msg[:role].nil? || msg[:role].empty?
19
18
 
20
- raise Riffer::ArgumentError, "Message hash must include a 'role' key" if role.nil? || role.empty?
21
-
22
- id = msg[:id]
23
-
24
- case role.to_sym
19
+ case msg[:role].to_sym
25
20
  when :user
26
21
  files = (msg[:files] || []).map { |f| Riffer::Messages::FilePart.from_hash(f) }
27
- Riffer::Messages::User.new(content, id: id, files: files)
22
+ Riffer::Messages::User.new(msg[:content], id: msg[:id], files: files)
28
23
  when :assistant
29
24
  tool_calls = (msg[:tool_calls] || []).map do |tc|
30
25
  tc.is_a?(Riffer::Messages::Assistant::ToolCall) ? tc : Riffer::Messages::Assistant::ToolCall.new(**tc)
31
26
  end
32
- structured_output = msg[:structured_output]
33
- finish_reason = msg[:finish_reason]&.to_sym
34
27
  Riffer::Messages::Assistant.new(
35
- content,
36
- id: id,
28
+ msg[:content],
29
+ id: msg[:id],
37
30
  tool_calls: tool_calls,
38
- structured_output: structured_output,
39
- finish_reason: finish_reason,
31
+ structured_output: msg[:structured_output],
32
+ finish_reason: msg[:finish_reason]&.to_sym,
33
+ finish_reason_raw: msg[:finish_reason_raw],
40
34
  )
41
35
  when :system
42
- Riffer::Messages::System.new(content, id: id)
36
+ Riffer::Messages::System.new(msg[:content], id: msg[:id])
43
37
  when :tool
44
- tool_call_id = msg[:tool_call_id]
45
- name = msg[:name]
46
- Riffer::Messages::Tool.new(content, id: id, tool_call_id: tool_call_id, name: name)
38
+ Riffer::Messages::Tool.new(msg[:content], id: msg[:id], tool_call_id: msg[:tool_call_id], name: msg[:name])
47
39
  else
48
- raise Riffer::ArgumentError, "Unknown message role: #{role}"
40
+ raise Riffer::ArgumentError, "Unknown message role: #{msg[:role]}"
49
41
  end
50
42
  end
51
43
 
@@ -121,6 +121,8 @@ class Riffer::Params::Param
121
121
 
122
122
  if [Riffer::Params::Boolean, TrueClass, FalseClass].include?(type)
123
123
  [true, false].include?(value)
124
+ elsif type == Float
125
+ value.is_a?(Numeric)
124
126
  else
125
127
  value.is_a?(type)
126
128
  end
data/lib/riffer/params.rb CHANGED
@@ -79,6 +79,11 @@ class Riffer::Params
79
79
 
80
80
  # Validates arguments against parameter definitions.
81
81
  #
82
+ # A Float param accepts an Integer (JSON Schema <tt>"number"</tt> covers
83
+ # integers) and its value is coerced with +to_f+, so callers always get a
84
+ # Float. The same holds for the items of an <tt>of: Float</tt> array. No other
85
+ # type is coerced.
86
+ #
82
87
  # Raises Riffer::ValidationError if validation fails.
83
88
  #
84
89
  #--
@@ -112,7 +117,7 @@ class Riffer::Params
112
117
 
113
118
  value = validate_nested(param, value, errors)
114
119
 
115
- validated[param.name] = value
120
+ validated[param.name] = coerce_value(param.type, value)
116
121
  end
117
122
 
118
123
  raise Riffer::ValidationError, errors.join("; ") if errors.any?
@@ -178,7 +183,6 @@ class Riffer::Params
178
183
  validate_nested_array_of_objects(param, value, errors)
179
184
  elsif param.type == Array && param.item_type
180
185
  validate_typed_array(param, value, errors)
181
- value
182
186
  else
183
187
  value
184
188
  end
@@ -218,20 +222,40 @@ class Riffer::Params
218
222
  end
219
223
  end
220
224
 
225
+ # Returns the array with its valid items coerced by +coerce_value+.
221
226
  #--
222
- #: (Riffer::Params::Param, Array[untyped], Array[String]) -> void
227
+ #: (Riffer::Params::Param, Array[untyped], Array[String]) -> Array[untyped]
223
228
  def validate_typed_array(param, value, errors)
224
229
  item_type = param.item_type
225
- return unless item_type
230
+ return value unless item_type
226
231
 
227
232
  type_name = Riffer::Params::Param::TYPE_MAPPINGS[item_type]
228
233
  valid_item = if [Riffer::Params::Boolean, TrueClass, FalseClass].include?(item_type)
229
234
  ->(item) { [true, false].include?(item) }
235
+ elsif item_type == Float
236
+ ->(item) { item.is_a?(Numeric) }
230
237
  else
231
238
  ->(item) { item.is_a?(item_type) }
232
239
  end
233
- value.each_with_index do |item, i|
234
- errors << "#{param.name}[#{i}] must be a #{type_name}" unless valid_item.call(item)
240
+ value.map.with_index do |item, i|
241
+ unless valid_item.call(item)
242
+ errors << "#{param.name}[#{i}] must be a #{type_name}"
243
+ next item
244
+ end
245
+
246
+ coerce_value(item_type, item)
235
247
  end
236
248
  end
249
+
250
+ # Coerces an already-validated value to the Ruby type its param declares.
251
+ # Only Float coerces today, because JSON Schema "number" accepts integers and
252
+ # callers should not get a type that depends on whether the model wrote a
253
+ # decimal point. Add a branch here rather than inline at a call site.
254
+ #--
255
+ #: (Module, untyped) -> untyped
256
+ def coerce_value(type, value)
257
+ return value.to_f if type == Float
258
+
259
+ value
260
+ end
237
261
  end
@@ -96,7 +96,12 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
96
96
  # Use strict schema to make optional fields nullable. Without this,
97
97
  # Anthropic may return empty strings or whitespace instead of null
98
98
  # for optional fields that the model has no value for.
99
+ #
100
+ # Merged over any caller-supplied output_config (e.g. effort) so those
101
+ # keys survive; the structured-output format wins because the run loop
102
+ # validates the response against it.
99
103
  params[:output_config] = {
104
+ **(params[:output_config] || {}),
100
105
  format: {
101
106
  type: "json_schema",
102
107
  schema: structured_output.json_schema(strict: true),
@@ -71,6 +71,7 @@ class Riffer::Providers::Base
71
71
  token_usage: token_usage,
72
72
  structured_output: structured_output,
73
73
  finish_reason: finish_reason&.reason,
74
+ finish_reason_raw: finish_reason&.raw,
74
75
  )
75
76
  end
76
77
  end
@@ -5,6 +5,8 @@
5
5
  # +max_concurrency+ caps simultaneous fibers via an <tt>Async::Semaphore</tt>.
6
6
  # If multiple fibers raise, only the first exception is re-raised after all
7
7
  # finish.
8
+ # Joins the current reactor task when one is already running, and otherwise
9
+ # starts its own.
8
10
  class Riffer::Runner::Fibers < Riffer::Runner
9
11
  # @rbs @max_concurrency: Integer?
10
12
 
@@ -25,15 +27,15 @@ class Riffer::Runner::Fibers < Riffer::Runner
25
27
  results = Array.new(items.size)
26
28
  errors = Array.new(items.size)
27
29
 
28
- Async do
29
- barrier = Async::Barrier.new
30
- max = @max_concurrency
31
- parent = if max
32
- Async::Semaphore.new(max, parent: barrier)
33
- else
34
- barrier
35
- end
30
+ barrier = Async::Barrier.new
31
+ max = @max_concurrency
32
+ parent = if max
33
+ Async::Semaphore.new(max, parent: barrier)
34
+ else
35
+ barrier
36
+ end
36
37
 
38
+ Sync do
37
39
  items.each_with_index do |item, index|
38
40
  parent.async do
39
41
  results[index] = yield(item)
@@ -43,6 +45,8 @@ class Riffer::Runner::Fibers < Riffer::Runner
43
45
  end
44
46
 
45
47
  barrier.wait
48
+ ensure
49
+ barrier.stop
46
50
  end
47
51
 
48
52
  first_error = errors.compact.first
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module Riffer
5
- VERSION = "0.45.0" #: String
5
+ VERSION = "0.46.1" #: String
6
6
  end
@@ -10,6 +10,8 @@ module Async
10
10
  def async: () { () -> void } -> untyped
11
11
 
12
12
  def wait: () -> void
13
+
14
+ def stop: () -> void
13
15
  end
14
16
 
15
17
  class Semaphore
@@ -21,4 +23,6 @@ end
21
23
 
22
24
  module Kernel
23
25
  def Async: () { () -> void } -> untyped
26
+
27
+ def Sync: () { () -> void } -> untyped
24
28
  end
@@ -0,0 +1,41 @@
1
+ # Generated from lib/riffer/agent/outcome.rb with RBS::Inline
2
+
3
+ # How a run ended — the single place to read whether the agent completed
4
+ # normally and, if not, why. +detail+ carries the specifics when there are any:
5
+ # the tripwire reason, the interrupt reason, the provider's raw finish value,
6
+ # or the structured output parse/validation error.
7
+ #
8
+ # response = agent.generate("Analyze this")
9
+ # case response.outcome.reason
10
+ # when :completed then puts response.structured_output
11
+ # when :invalid_structured_output then warn response.outcome.detail
12
+ # end
13
+ class Riffer::Agent::Outcome
14
+ # Finish reasons that end a turn normally; every other finish reason means the
15
+ # provider cut the turn short and surfaces as the run's outcome verbatim.
16
+ NORMAL_FINISH_REASONS: Array[Symbol]
17
+
18
+ # Derived from the provider vocabulary so a new finish reason becomes an
19
+ # outcome without a second list to update.
20
+ PROVIDER_STOP_REASONS: Array[Symbol]
21
+
22
+ # The vocabulary every run ends in.
23
+ VALUES: Array[Symbol]
24
+
25
+ # Why the run ended.
26
+ attr_reader reason: Symbol
27
+
28
+ # Human-readable specifics for +reason+, when there are any.
29
+ attr_reader detail: String?
30
+
31
+ # Raises Riffer::ArgumentError when +reason+ is outside VALUES.
32
+ # --
33
+ # : (reason: Symbol, ?detail: String?) -> void
34
+ def initialize: (reason: Symbol, ?detail: String?) -> void
35
+
36
+ # Returns true when the run completed normally.
37
+ #
38
+ # --
39
+ # : () -> bool
40
+ def success?: () -> bool
41
+ end