riffer 0.44.0 → 0.46.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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +24 -0
- data/docs/AGENTS.md +13 -0
- data/docs/AGENT_LIFECYCLE.md +39 -14
- data/docs/AGENT_LOOP.md +8 -9
- data/docs/CONFIGURATION.md +24 -17
- data/docs/GUARDRAILS.md +4 -4
- data/docs/MESSAGES.md +14 -11
- data/docs/STREAM_EVENTS.md +1 -1
- data/docs/TOOL_ADVANCED.md +2 -0
- data/docs/TRACING.md +4 -2
- data/docs/providers/CUSTOM_PROVIDERS.md +1 -1
- data/docs/providers/MOCK_PROVIDER.md +18 -11
- data/lib/riffer/agent/outcome.rb +52 -0
- data/lib/riffer/agent/response.rb +12 -33
- data/lib/riffer/agent/run.rb +54 -16
- data/lib/riffer/agent/session.rb +2 -0
- data/lib/riffer/messages/assistant.rb +24 -2
- data/lib/riffer/messages/base.rb +11 -19
- data/lib/riffer/providers/amazon_bedrock.rb +3 -0
- data/lib/riffer/providers/anthropic.rb +9 -0
- data/lib/riffer/providers/base.rb +1 -0
- data/lib/riffer/providers/finish_reason.rb +1 -1
- data/lib/riffer/providers/gemini.rb +10 -1
- data/lib/riffer/providers/open_ai.rb +30 -23
- data/lib/riffer/providers/open_router.rb +21 -7
- data/lib/riffer/runner/fibers.rb +12 -8
- data/lib/riffer/version.rb +1 -1
- data/sig/_private/async.rbs +4 -0
- data/sig/generated/riffer/agent/outcome.rbs +41 -0
- data/sig/generated/riffer/agent/response.rbs +11 -26
- data/sig/generated/riffer/agent/run.rbs +18 -7
- data/sig/generated/riffer/messages/assistant.rbs +14 -2
- data/sig/generated/riffer/providers/open_ai.rbs +8 -3
- data/sig/generated/riffer/providers/open_router.rbs +10 -2
- data/sig/generated/riffer/runner/fibers.rbs +2 -0
- metadata +3 -1
|
@@ -1,30 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
|
-
# Wraps an agent generation response.
|
|
5
|
-
# +content+ is empty and +tripwire+ carries the
|
|
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.
|
|
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
|
data/lib/riffer/agent/run.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
228
|
+
message&.content || "",
|
|
229
|
+
outcome: final_outcome(message, result, interrupted: interrupted, interrupt_reason: interrupt_reason),
|
|
224
230
|
modifications: all_modifications,
|
|
225
|
-
structured_output:
|
|
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?) ->
|
|
331
|
-
def
|
|
332
|
-
return unless
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/riffer/agent/session.rb
CHANGED
|
@@ -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(
|
|
@@ -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
|
-
#: (
|
|
26
|
-
|
|
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
|
data/lib/riffer/messages/base.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -17,6 +17,9 @@ class Riffer::Providers::AmazonBedrock < Riffer::Providers::Base
|
|
|
17
17
|
"tool_use" => :tool_calls,
|
|
18
18
|
"guardrail_intervened" => :content_filter,
|
|
19
19
|
"content_filtered" => :content_filter,
|
|
20
|
+
"malformed_model_output" => :malformed_output,
|
|
21
|
+
"malformed_tool_use" => :malformed_output,
|
|
22
|
+
"model_context_window_exceeded" => :context_window,
|
|
20
23
|
}.freeze #: Hash[String, Symbol]
|
|
21
24
|
|
|
22
25
|
# Returns the skill adapter for the Bedrock model — XML for Anthropic models
|
|
@@ -12,6 +12,10 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
|
|
|
12
12
|
"max_tokens" => :length,
|
|
13
13
|
"tool_use" => :tool_calls,
|
|
14
14
|
"refusal" => :content_filter,
|
|
15
|
+
"model_context_window_exceeded" => :context_window,
|
|
16
|
+
# A paused server-tool turn resumes only by re-sending the response; the
|
|
17
|
+
# agent loop does not do that, so it has no normalized equivalent.
|
|
18
|
+
"pause_turn" => :other,
|
|
15
19
|
}.freeze #: Hash[String, Symbol]
|
|
16
20
|
|
|
17
21
|
# Returns the XML skill adapter for Anthropic/Claude.
|
|
@@ -92,7 +96,12 @@ class Riffer::Providers::Anthropic < Riffer::Providers::Base
|
|
|
92
96
|
# Use strict schema to make optional fields nullable. Without this,
|
|
93
97
|
# Anthropic may return empty strings or whitespace instead of null
|
|
94
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.
|
|
95
103
|
params[:output_config] = {
|
|
104
|
+
**(params[:output_config] || {}),
|
|
96
105
|
format: {
|
|
97
106
|
type: "json_schema",
|
|
98
107
|
schema: structured_output.json_schema(strict: true),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# wire value. +reason+ carries the same meaning for every provider.
|
|
6
6
|
class Riffer::Providers::FinishReason
|
|
7
7
|
# The normalized vocabulary every provider maps into.
|
|
8
|
-
VALUES = %i[stop length tool_calls content_filter error other].freeze #: Array[Symbol]
|
|
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
11
|
attr_reader :reason #: Symbol
|
|
@@ -17,7 +17,16 @@ class Riffer::Providers::Gemini < Riffer::Providers::Base
|
|
|
17
17
|
"PROHIBITED_CONTENT" => :content_filter,
|
|
18
18
|
"SPII" => :content_filter,
|
|
19
19
|
"IMAGE_SAFETY" => :content_filter,
|
|
20
|
-
"
|
|
20
|
+
"IMAGE_PROHIBITED_CONTENT" => :content_filter,
|
|
21
|
+
"IMAGE_RECITATION" => :content_filter,
|
|
22
|
+
"LANGUAGE" => :content_filter,
|
|
23
|
+
"MALFORMED_FUNCTION_CALL" => :malformed_output,
|
|
24
|
+
"UNEXPECTED_TOOL_CALL" => :malformed_output,
|
|
25
|
+
"NO_IMAGE" => :error,
|
|
26
|
+
"TOO_MANY_TOOL_CALLS" => :other,
|
|
27
|
+
"OTHER" => :other,
|
|
28
|
+
"IMAGE_OTHER" => :other,
|
|
29
|
+
"FINISH_REASON_UNSPECIFIED" => :other,
|
|
21
30
|
}.freeze #: Hash[String, Symbol]
|
|
22
31
|
|
|
23
32
|
# The GenAI semconv well-known provider name.
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
6
6
|
WEB_SEARCH_TOOL_TYPE = "web_search_preview" #: String
|
|
7
7
|
|
|
8
|
+
# The Responses API has no finish_reason field. The response +status+ is
|
|
9
|
+
# the primary signal; an +incomplete+ status is only meaningful together
|
|
10
|
+
# with <tt>incomplete_details.reason</tt>, so that branch nests one level.
|
|
11
|
+
FINISH_REASONS = {
|
|
12
|
+
"completed" => :stop,
|
|
13
|
+
"incomplete" => {
|
|
14
|
+
"max_output_tokens" => :length,
|
|
15
|
+
"content_filter" => :content_filter,
|
|
16
|
+
},
|
|
17
|
+
"failed" => :error,
|
|
18
|
+
"cancelled" => :other,
|
|
19
|
+
"in_progress" => :other,
|
|
20
|
+
"queued" => :other,
|
|
21
|
+
}.freeze #: Hash[String, Symbol | Hash[String, Symbol]]
|
|
22
|
+
|
|
8
23
|
# The GenAI semconv well-known provider name.
|
|
9
24
|
#--
|
|
10
25
|
#: () -> String
|
|
@@ -133,40 +148,32 @@ class Riffer::Providers::OpenAI < Riffer::Providers::Base
|
|
|
133
148
|
build_finish_reason(response)
|
|
134
149
|
end
|
|
135
150
|
|
|
136
|
-
# The Responses API reports no finish_reason field, so one is derived.
|
|
137
151
|
#--
|
|
138
152
|
#: (untyped) -> Riffer::Providers::FinishReason?
|
|
139
153
|
def build_finish_reason(response)
|
|
140
154
|
typed_response = response #: OpenAI::Models::Responses::Response
|
|
141
|
-
status = typed_response.status
|
|
155
|
+
status = typed_response.status&.to_s
|
|
142
156
|
return nil unless status
|
|
143
157
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
Riffer::Providers::FinishReason.new(reason: :error, raw: "failed")
|
|
152
|
-
else
|
|
153
|
-
Riffer::Providers::FinishReason.new(reason: :other, raw: status.to_s)
|
|
154
|
-
end
|
|
158
|
+
detail = finish_detail(typed_response, status)
|
|
159
|
+
mapping = FINISH_REASONS.fetch(status, :other)
|
|
160
|
+
reason = mapping.is_a?(Hash) ? mapping.fetch(detail.to_s, :other) : mapping
|
|
161
|
+
# A completed response signals tool use only through its output items.
|
|
162
|
+
reason = :tool_calls if reason == :stop && !extract_tool_calls(typed_response).empty?
|
|
163
|
+
|
|
164
|
+
Riffer::Providers::FinishReason.new(reason: reason, raw: detail || status)
|
|
155
165
|
end
|
|
156
166
|
|
|
167
|
+
# The nested field that names the cause behind an ambiguous status.
|
|
157
168
|
#--
|
|
158
|
-
#: (untyped) ->
|
|
159
|
-
def
|
|
169
|
+
#: (untyped, String) -> String?
|
|
170
|
+
def finish_detail(response, status)
|
|
160
171
|
typed_response = response #: OpenAI::Models::Responses::Response
|
|
161
|
-
raw = typed_response.incomplete_details&.reason&.to_s
|
|
162
|
-
|
|
163
|
-
reason = case raw
|
|
164
|
-
when "max_output_tokens" then :length
|
|
165
|
-
when "content_filter" then :content_filter
|
|
166
|
-
else :other
|
|
167
|
-
end
|
|
168
172
|
|
|
169
|
-
|
|
173
|
+
case status
|
|
174
|
+
when "incomplete" then typed_response.incomplete_details&.reason&.to_s
|
|
175
|
+
when "failed" then typed_response.error&.code&.to_s
|
|
176
|
+
end
|
|
170
177
|
end
|
|
171
178
|
|
|
172
179
|
#--
|
|
@@ -133,18 +133,30 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
|
133
133
|
#: (untyped) -> Riffer::Providers::FinishReason?
|
|
134
134
|
def extract_finish_reason(response)
|
|
135
135
|
typed_response = response #: OpenAI::Models::Chat::ChatCompletion
|
|
136
|
-
|
|
136
|
+
choice = typed_response.choices.first
|
|
137
|
+
build_finish_reason(choice&.finish_reason, native: native_finish_reason(choice))
|
|
137
138
|
end
|
|
138
139
|
|
|
140
|
+
# +native+ is the upstream model's own finish reason, which OpenRouter
|
|
141
|
+
# reports alongside its normalized one; it wins as +raw+ when present.
|
|
139
142
|
#--
|
|
140
|
-
#: (untyped) -> Riffer::Providers::FinishReason?
|
|
141
|
-
def build_finish_reason(finish_reason)
|
|
143
|
+
#: (untyped, ?native: untyped) -> Riffer::Providers::FinishReason?
|
|
144
|
+
def build_finish_reason(finish_reason, native: nil)
|
|
142
145
|
return nil unless finish_reason
|
|
143
146
|
|
|
144
|
-
|
|
145
|
-
return nil if
|
|
147
|
+
normalized = finish_reason.to_s
|
|
148
|
+
return nil if normalized.empty?
|
|
149
|
+
|
|
150
|
+
raw = native.to_s.empty? ? normalized : native.to_s
|
|
151
|
+
Riffer::Providers::FinishReason.new(reason: FINISH_REASONS.fetch(normalized, :other), raw: raw)
|
|
152
|
+
end
|
|
146
153
|
|
|
147
|
-
|
|
154
|
+
# +native_finish_reason+ is outside the OpenAI schema, so it is only
|
|
155
|
+
# reachable through the SDK model's raw data hash.
|
|
156
|
+
#--
|
|
157
|
+
#: (untyped) -> untyped
|
|
158
|
+
def native_finish_reason(choice)
|
|
159
|
+
choice && choice.to_h[:native_finish_reason]
|
|
148
160
|
end
|
|
149
161
|
|
|
150
162
|
#--
|
|
@@ -187,6 +199,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
|
187
199
|
reasoning: +"",
|
|
188
200
|
tool_calls: {},
|
|
189
201
|
finish_reason: nil,
|
|
202
|
+
native_finish_reason: nil,
|
|
190
203
|
} #: Hash[Symbol, untyped]
|
|
191
204
|
|
|
192
205
|
# Use stream_raw (not stream) — the latter yields a higher-level
|
|
@@ -211,7 +224,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
|
211
224
|
|
|
212
225
|
yielder << Riffer::StreamEvents::TextDone.new(state[:text]) unless state[:text].empty?
|
|
213
226
|
yielder << Riffer::StreamEvents::ReasoningDone.new(state[:reasoning]) unless state[:reasoning].empty?
|
|
214
|
-
yield_finish_reason(yielder, build_finish_reason(state[:finish_reason]))
|
|
227
|
+
yield_finish_reason(yielder, build_finish_reason(state[:finish_reason], native: state[:native_finish_reason]))
|
|
215
228
|
end
|
|
216
229
|
|
|
217
230
|
#--
|
|
@@ -228,6 +241,7 @@ class Riffer::Providers::OpenRouter < Riffer::Providers::Base
|
|
|
228
241
|
end
|
|
229
242
|
|
|
230
243
|
state[:finish_reason] = choice.finish_reason if choice&.finish_reason
|
|
244
|
+
state[:native_finish_reason] = native_finish_reason(choice) || state[:native_finish_reason]
|
|
231
245
|
|
|
232
246
|
emit_tool_call_done_events(state: state, yielder: yielder) if choice && finish_reason_is_tool_calls?(choice)
|
|
233
247
|
|
data/lib/riffer/runner/fibers.rb
CHANGED
|
@@ -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
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
data/lib/riffer/version.rb
CHANGED
data/sig/_private/async.rbs
CHANGED
|
@@ -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
|