riffer 0.32.0 → 0.33.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/.ruby-version +1 -1
- data/CHANGELOG.md +34 -0
- data/README.md +13 -11
- data/docs/01_OVERVIEW.md +2 -0
- data/docs/04_AGENT_LIFECYCLE.md +15 -13
- data/docs/08_MESSAGES.md +39 -5
- data/docs/09_STREAM_EVENTS.md +14 -0
- data/docs/10_CONFIGURATION.md +73 -4
- data/docs/13_SKILLS.md +66 -4
- data/docs/14_MCP.md +2 -1
- data/docs/16_TRACING.md +250 -0
- data/docs/17_METRICS.md +123 -0
- data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
- data/lib/riffer/agent/response.rb +11 -2
- data/lib/riffer/agent/run.rb +136 -35
- data/lib/riffer/agent.rb +5 -5
- data/lib/riffer/config.rb +231 -15
- data/lib/riffer/guardrail.rb +8 -0
- data/lib/riffer/guardrails/runner.rb +33 -0
- data/lib/riffer/helpers/boolean.rb +22 -0
- data/lib/riffer/mcp/authenticated_tool.rb +14 -20
- data/lib/riffer/mcp/registration.rb +4 -4
- data/lib/riffer/mcp/tool.rb +23 -0
- data/lib/riffer/mcp/tool_factory.rb +14 -22
- data/lib/riffer/messages/assistant.rb +15 -3
- data/lib/riffer/messages/base.rb +2 -1
- data/lib/riffer/metrics/instruments.rb +25 -0
- data/lib/riffer/metrics/null.rb +14 -0
- data/lib/riffer/metrics/otel.rb +79 -0
- data/lib/riffer/metrics.rb +93 -0
- data/lib/riffer/providers/amazon_bedrock.rb +57 -21
- data/lib/riffer/providers/anthropic.rb +59 -24
- data/lib/riffer/providers/azure_open_ai.rb +7 -0
- data/lib/riffer/providers/base.rb +247 -15
- data/lib/riffer/providers/finish_reason.rb +27 -0
- data/lib/riffer/providers/gemini.rb +59 -11
- data/lib/riffer/providers/mock.rb +30 -9
- data/lib/riffer/providers/open_ai.rb +78 -24
- data/lib/riffer/providers/open_router.rb +56 -16
- data/lib/riffer/providers/repository.rb +9 -0
- data/lib/riffer/providers/token_usage.rb +27 -11
- data/lib/riffer/skills/activate_tool.rb +12 -2
- data/lib/riffer/skills/adapter.rb +15 -0
- data/lib/riffer/skills/context.rb +78 -11
- data/lib/riffer/skills/frontmatter.rb +13 -5
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
- data/lib/riffer/tools/runtime.rb +99 -3
- data/lib/riffer/tracing/capture.rb +92 -0
- data/lib/riffer/tracing/null.rb +61 -0
- data/lib/riffer/tracing/otel.rb +131 -0
- data/lib/riffer/tracing/stream_recorder.rb +51 -0
- data/lib/riffer/tracing.rb +78 -0
- data/lib/riffer/version.rb +1 -1
- data/sig/_private/opentelemetry.rbs +22 -0
- data/sig/generated/riffer/agent/response.rbs +9 -2
- data/sig/generated/riffer/agent/run.rbs +28 -8
- data/sig/generated/riffer/config.rbs +162 -16
- data/sig/generated/riffer/guardrail.rbs +6 -0
- data/sig/generated/riffer/guardrails/runner.rbs +14 -0
- data/sig/generated/riffer/helpers/boolean.rbs +11 -0
- data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
- data/sig/generated/riffer/mcp/registration.rbs +4 -4
- data/sig/generated/riffer/mcp/tool.rbs +19 -0
- data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
- data/sig/generated/riffer/messages/assistant.rbs +10 -4
- data/sig/generated/riffer/metrics/instruments.rbs +13 -0
- data/sig/generated/riffer/metrics/null.rbs +10 -0
- data/sig/generated/riffer/metrics/otel.rbs +47 -0
- data/sig/generated/riffer/metrics.rbs +71 -0
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
- data/sig/generated/riffer/providers/anthropic.rbs +41 -20
- data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
- data/sig/generated/riffer/providers/base.rbs +78 -2
- data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
- data/sig/generated/riffer/providers/gemini.rbs +25 -2
- data/sig/generated/riffer/providers/mock.rbs +16 -5
- data/sig/generated/riffer/providers/open_ai.rbs +44 -22
- data/sig/generated/riffer/providers/open_router.rbs +31 -12
- data/sig/generated/riffer/providers/repository.rbs +7 -0
- data/sig/generated/riffer/providers/token_usage.rbs +20 -10
- data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
- data/sig/generated/riffer/skills/adapter.rbs +10 -0
- data/sig/generated/riffer/skills/context.rbs +52 -4
- data/sig/generated/riffer/skills/frontmatter.rbs +10 -3
- data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
- data/sig/generated/riffer/tools/runtime.rbs +35 -0
- data/sig/generated/riffer/tracing/capture.rbs +46 -0
- data/sig/generated/riffer/tracing/null.rbs +46 -0
- data/sig/generated/riffer/tracing/otel.rbs +83 -0
- data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
- data/sig/generated/riffer/tracing.rbs +52 -0
- data/sig/manual/riffer/helpers/boolean.rbs +5 -0
- data/sig/manual/riffer/metrics/null.rbs +5 -0
- data/sig/manual/riffer/metrics.rbs +5 -0
- data/sig/manual/riffer/providers.rbs +9 -0
- data/sig/manual/riffer/tracing/capture.rbs +5 -0
- data/sig/manual/riffer/tracing/null.rbs +5 -0
- data/sig/manual/riffer/tracing.rbs +5 -0
- metadata +40 -4
data/lib/riffer/agent/run.rb
CHANGED
|
@@ -23,7 +23,12 @@ module Riffer::Agent::Run
|
|
|
23
23
|
#: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
24
24
|
def stream(agent:, prompt: nil, files: nil)
|
|
25
25
|
append_user_message(agent, prompt, files: files)
|
|
26
|
-
|
|
26
|
+
# The enumerator body runs in its own fiber, where the fiber-local OTEL
|
|
27
|
+
# context is empty — capture here so the run span parents to the caller's trace.
|
|
28
|
+
trace_context = Riffer::Tracing.current_context
|
|
29
|
+
Enumerator.new do |stream_yielder|
|
|
30
|
+
Riffer::Tracing.with_context(trace_context) { run_loop(agent, stream_yielder: stream_yielder) }
|
|
31
|
+
end
|
|
27
32
|
end
|
|
28
33
|
|
|
29
34
|
private
|
|
@@ -31,45 +36,89 @@ module Riffer::Agent::Run
|
|
|
31
36
|
#--
|
|
32
37
|
#: (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
|
|
33
38
|
def run_loop(agent, stream_yielder: nil)
|
|
39
|
+
start = Riffer::Metrics.monotonic_now
|
|
40
|
+
error_type = nil #: String?
|
|
41
|
+
begin
|
|
42
|
+
Riffer::Tracing.in_span("invoke_agent #{agent.class.identifier}", attributes: run_span_attributes(agent), kind: :internal) do |span|
|
|
43
|
+
response = execute_run(agent, stream_yielder)
|
|
44
|
+
record_run_outcome(span, response)
|
|
45
|
+
response
|
|
46
|
+
rescue => error
|
|
47
|
+
# The backend records the exception and error status on the re-raise;
|
|
48
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
49
|
+
span.set_attribute("error.type", error.class.name)
|
|
50
|
+
raise
|
|
51
|
+
end
|
|
52
|
+
rescue => error
|
|
53
|
+
# The inner rescue tags the span; capture error.type here too, at method
|
|
54
|
+
# scope, where the ensure can read it onto the metric.
|
|
55
|
+
error_type = error.class.name #: String?
|
|
56
|
+
raise
|
|
57
|
+
ensure
|
|
58
|
+
Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: run_metric_attributes(agent, error_type))
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
#--
|
|
63
|
+
#: (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
|
|
64
|
+
def execute_run(agent, stream_yielder)
|
|
34
65
|
all_modifications = [] #: Array[Riffer::Guardrails::Modification]
|
|
66
|
+
run_usage = nil #: Riffer::Providers::TokenUsage?
|
|
67
|
+
run_steps = 0
|
|
35
68
|
|
|
36
|
-
run_before_guardrails(agent, stream_yielder, all_modifications)
|
|
69
|
+
run_before_guardrails(agent, stream_yielder, all_modifications) do |tripwire|
|
|
70
|
+
return tripwire_response(agent, stream_yielder, tripwire, all_modifications, steps: run_steps)
|
|
71
|
+
end
|
|
37
72
|
|
|
38
73
|
skills = agent.context.skills
|
|
74
|
+
consumer_on_activate = skills&.on_activate
|
|
39
75
|
|
|
40
76
|
if stream_yielder && skills
|
|
41
|
-
skills.on_activate = ->(name) {
|
|
77
|
+
skills.on_activate = ->(name) {
|
|
78
|
+
consumer_on_activate&.call(name)
|
|
79
|
+
stream_yielder << Riffer::StreamEvents::SkillActivation.new(name)
|
|
80
|
+
}
|
|
42
81
|
end
|
|
43
82
|
|
|
44
|
-
|
|
83
|
+
begin
|
|
84
|
+
step = agent.session.steps
|
|
45
85
|
|
|
46
|
-
|
|
47
|
-
|
|
86
|
+
reason = catch(:riffer_interrupt) do
|
|
87
|
+
execute_pending_tool_calls(agent)
|
|
48
88
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
89
|
+
loop do
|
|
90
|
+
response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
|
|
91
|
+
step += 1
|
|
92
|
+
run_steps += 1
|
|
93
|
+
track_token_usage(agent, response.token_usage)
|
|
94
|
+
run_usage = sum_usage(run_usage, response.token_usage)
|
|
53
95
|
|
|
54
|
-
|
|
96
|
+
processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) do |tripwire|
|
|
97
|
+
return tripwire_response(agent, stream_yielder, tripwire, all_modifications, token_usage: run_usage, steps: run_steps)
|
|
98
|
+
end
|
|
55
99
|
|
|
56
|
-
|
|
100
|
+
agent.session.add(processed_response)
|
|
57
101
|
|
|
58
|
-
|
|
102
|
+
break unless processed_response.has_tool_calls?
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
max_steps = agent.config.max_steps
|
|
105
|
+
throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
|
|
62
106
|
|
|
63
|
-
|
|
107
|
+
execute_tool_calls(agent, processed_response)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
return final_response(agent, all_modifications, token_usage: run_usage, steps: run_steps)
|
|
64
111
|
end
|
|
65
112
|
|
|
66
|
-
|
|
113
|
+
new_messages, filled = Riffer::Agent::Session::Repair.fill_orphans(agent.session.messages)
|
|
114
|
+
agent.session.set(new_messages)
|
|
115
|
+
stream_yielder << Riffer::StreamEvents::Interrupt.new(reason: reason, healed_tool_call_ids: filled) if stream_yielder
|
|
116
|
+
final_response(agent, all_modifications, interrupted: true, interrupt_reason: reason, healed_tool_call_ids: filled, token_usage: run_usage, steps: run_steps)
|
|
117
|
+
ensure
|
|
118
|
+
# The stream wiring must not outlive the run — a leaked lambda would push
|
|
119
|
+
# later harness-side activations into a dead Enumerator::Yielder.
|
|
120
|
+
skills.on_activate = consumer_on_activate if stream_yielder && skills
|
|
67
121
|
end
|
|
68
|
-
|
|
69
|
-
new_messages, filled = Riffer::Agent::Session::Repair.fill_orphans(agent.session.messages)
|
|
70
|
-
agent.session.set(new_messages)
|
|
71
|
-
stream_yielder << Riffer::StreamEvents::Interrupt.new(reason: reason, healed_tool_call_ids: filled) if stream_yielder
|
|
72
|
-
final_response(agent, all_modifications, interrupted: true, interrupt_reason: reason, healed_tool_call_ids: filled)
|
|
73
122
|
end
|
|
74
123
|
|
|
75
124
|
#--
|
|
@@ -78,6 +127,7 @@ module Riffer::Agent::Run
|
|
|
78
127
|
accumulated_content = ""
|
|
79
128
|
accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
|
|
80
129
|
accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
|
|
130
|
+
accumulated_finish_reason = nil #: Symbol?
|
|
81
131
|
|
|
82
132
|
call_llm_stream(agent).each do |event|
|
|
83
133
|
stream_yielder << event
|
|
@@ -95,13 +145,16 @@ module Riffer::Agent::Run
|
|
|
95
145
|
)
|
|
96
146
|
when Riffer::StreamEvents::TokenUsageDone
|
|
97
147
|
accumulated_token_usage = event.token_usage
|
|
148
|
+
when Riffer::StreamEvents::FinishReasonDone
|
|
149
|
+
accumulated_finish_reason = event.finish_reason
|
|
98
150
|
end
|
|
99
151
|
end
|
|
100
152
|
|
|
101
153
|
Riffer::Messages::Assistant.new(
|
|
102
154
|
accumulated_content,
|
|
103
155
|
tool_calls: accumulated_tool_calls,
|
|
104
|
-
token_usage: accumulated_token_usage
|
|
156
|
+
token_usage: accumulated_token_usage,
|
|
157
|
+
finish_reason: accumulated_finish_reason
|
|
105
158
|
)
|
|
106
159
|
end
|
|
107
160
|
|
|
@@ -113,10 +166,10 @@ module Riffer::Agent::Run
|
|
|
113
166
|
end
|
|
114
167
|
|
|
115
168
|
#--
|
|
116
|
-
#: (Riffer::Agent, Enumerator::Yielder?, Riffer::Guardrails::Tripwire, Array[Riffer::Guardrails::Modification]) -> Riffer::Agent::Response
|
|
117
|
-
def tripwire_response(agent, stream_yielder, tripwire, all_modifications)
|
|
169
|
+
#: (Riffer::Agent, Enumerator::Yielder?, Riffer::Guardrails::Tripwire, Array[Riffer::Guardrails::Modification], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
|
|
170
|
+
def tripwire_response(agent, stream_yielder, tripwire, all_modifications, token_usage: nil, steps: 0)
|
|
118
171
|
stream_yielder << Riffer::StreamEvents::GuardrailTripwire.new(tripwire) if stream_yielder
|
|
119
|
-
build_response(agent, "", tripwire: tripwire, modifications: all_modifications)
|
|
172
|
+
build_response(agent, "", tripwire: tripwire, modifications: all_modifications, token_usage: token_usage, steps: steps)
|
|
120
173
|
end
|
|
121
174
|
|
|
122
175
|
#--
|
|
@@ -187,7 +240,7 @@ module Riffer::Agent::Run
|
|
|
187
240
|
end
|
|
188
241
|
|
|
189
242
|
#--
|
|
190
|
-
#: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::
|
|
243
|
+
#: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
|
|
191
244
|
def run_before_guardrails(agent, stream_yielder, all_modifications)
|
|
192
245
|
guardrails = agent.config.guardrails_for(:before)
|
|
193
246
|
return if guardrails.empty?
|
|
@@ -196,11 +249,11 @@ module Riffer::Agent::Run
|
|
|
196
249
|
processed_messages, tripwire, modifications = runner.run(agent.session.messages)
|
|
197
250
|
agent.session.set(processed_messages) unless tripwire
|
|
198
251
|
record_modifications!(stream_yielder, all_modifications, modifications)
|
|
199
|
-
yield
|
|
252
|
+
yield tripwire if tripwire
|
|
200
253
|
end
|
|
201
254
|
|
|
202
255
|
#--
|
|
203
|
-
#: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::
|
|
256
|
+
#: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
|
|
204
257
|
def run_after_guardrails(agent, response, stream_yielder, all_modifications)
|
|
205
258
|
guardrails = agent.config.guardrails_for(:after)
|
|
206
259
|
return response if guardrails.empty?
|
|
@@ -212,7 +265,7 @@ module Riffer::Agent::Run
|
|
|
212
265
|
modifications.each { |m| m.message_indices.map! { response_index } }
|
|
213
266
|
|
|
214
267
|
record_modifications!(stream_yielder, all_modifications, modifications)
|
|
215
|
-
yield
|
|
268
|
+
yield tripwire if tripwire
|
|
216
269
|
|
|
217
270
|
processed_response
|
|
218
271
|
end
|
|
@@ -241,10 +294,10 @@ module Riffer::Agent::Run
|
|
|
241
294
|
end
|
|
242
295
|
|
|
243
296
|
#--
|
|
244
|
-
#: (Riffer::Agent, String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String]) -> Riffer::Agent::Response
|
|
245
|
-
def build_response(agent, content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, healed_tool_call_ids: [])
|
|
297
|
+
#: (Riffer::Agent, String, ?tripwire: Riffer::Guardrails::Tripwire?, ?modifications: Array[Riffer::Guardrails::Modification], ?interrupted: bool, ?interrupt_reason: (String | Symbol)?, ?structured_output: Hash[Symbol, untyped]?, ?healed_tool_call_ids: Array[String], ?token_usage: Riffer::Providers::TokenUsage?, ?steps: Integer) -> Riffer::Agent::Response
|
|
298
|
+
def build_response(agent, content, tripwire: nil, modifications: [], interrupted: false, interrupt_reason: nil, structured_output: nil, healed_tool_call_ids: [], token_usage: nil, steps: 0)
|
|
246
299
|
messages = agent.session.messages
|
|
247
|
-
Riffer::Agent::Response.new(content, tripwire: tripwire, modifications: modifications, interrupted: interrupted, interrupt_reason: interrupt_reason, structured_output: structured_output, messages: messages.frozen? ? messages : messages.dup.freeze, healed_tool_call_ids: healed_tool_call_ids)
|
|
300
|
+
Riffer::Agent::Response.new(content, tripwire: tripwire, modifications: modifications, interrupted: interrupted, interrupt_reason: interrupt_reason, structured_output: structured_output, messages: messages.frozen? ? messages : messages.dup.freeze, healed_tool_call_ids: healed_tool_call_ids, token_usage: token_usage, steps: steps)
|
|
248
301
|
end
|
|
249
302
|
|
|
250
303
|
# Raises when +files+ are supplied without a +prompt+ — the provider needs
|
|
@@ -264,7 +317,55 @@ module Riffer::Agent::Run
|
|
|
264
317
|
def track_token_usage(agent, usage)
|
|
265
318
|
return unless usage
|
|
266
319
|
|
|
267
|
-
|
|
268
|
-
|
|
320
|
+
agent.context.token_usage = sum_usage(agent.context.token_usage, usage)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
#--
|
|
324
|
+
#: (Riffer::Providers::TokenUsage?, Riffer::Providers::TokenUsage?) -> Riffer::Providers::TokenUsage?
|
|
325
|
+
def sum_usage(current, usage)
|
|
326
|
+
return current unless usage
|
|
327
|
+
|
|
328
|
+
current ? current + usage : usage
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
#--
|
|
332
|
+
#: (Riffer::Agent) -> Hash[String, untyped]
|
|
333
|
+
def run_span_attributes(agent)
|
|
334
|
+
{
|
|
335
|
+
"gen_ai.operation.name" => "invoke_agent",
|
|
336
|
+
"gen_ai.agent.name" => agent.class.identifier,
|
|
337
|
+
"gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
|
|
338
|
+
"gen_ai.request.model" => agent.model_name
|
|
339
|
+
}
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
#--
|
|
343
|
+
#: (Riffer::Agent, String?) -> Hash[String, untyped]
|
|
344
|
+
def run_metric_attributes(agent, error_type)
|
|
345
|
+
attributes = {
|
|
346
|
+
"gen_ai.operation.name" => "invoke_agent",
|
|
347
|
+
"gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
|
|
348
|
+
"gen_ai.request.model" => agent.model_name,
|
|
349
|
+
"gen_ai.agent.name" => agent.class.identifier
|
|
350
|
+
} #: Hash[String, untyped]
|
|
351
|
+
attributes["error.type"] = error_type if error_type
|
|
352
|
+
attributes
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
#--
|
|
356
|
+
#: (Riffer::Tracing::Otel::Span | Riffer::Tracing::Null::Span, Riffer::Agent::Response) -> void
|
|
357
|
+
def record_run_outcome(span, response)
|
|
358
|
+
span.set_attribute("riffer.steps", response.steps)
|
|
359
|
+
Riffer::Tracing.record_usage(span, response.token_usage)
|
|
360
|
+
|
|
361
|
+
span.set_attribute("riffer.interrupt.reason", response.interrupt_reason.to_s) if response.interrupt_reason
|
|
362
|
+
|
|
363
|
+
tripwire = response.tripwire
|
|
364
|
+
return unless tripwire
|
|
365
|
+
|
|
366
|
+
class_name = tripwire.guardrail.name
|
|
367
|
+
span.set_attribute("riffer.tripwire.guardrail", Riffer::Helpers::ClassNameConverter.convert(class_name)) if class_name
|
|
368
|
+
span.set_attribute("riffer.tripwire.reason", tripwire.reason)
|
|
369
|
+
span.set_attribute("riffer.tripwire.phase", tripwire.phase.to_s)
|
|
269
370
|
end
|
|
270
371
|
end
|
data/lib/riffer/agent.rb
CHANGED
|
@@ -343,9 +343,9 @@ class Riffer::Agent
|
|
|
343
343
|
#--
|
|
344
344
|
#: () -> Riffer::Messages::System?
|
|
345
345
|
def build_skills_message
|
|
346
|
-
|
|
347
|
-
return nil
|
|
348
|
-
Riffer::Messages::System.new(
|
|
346
|
+
content = @context.skills&.system_prompt
|
|
347
|
+
return nil if content.nil? || content.empty?
|
|
348
|
+
Riffer::Messages::System.new(content)
|
|
349
349
|
end
|
|
350
350
|
|
|
351
351
|
#--
|
|
@@ -395,7 +395,7 @@ class Riffer::Agent
|
|
|
395
395
|
|
|
396
396
|
if skills_config.activate
|
|
397
397
|
names = Array(Riffer::Helpers::CallOrValue.resolve(skills_config.activate, context: @context))
|
|
398
|
-
names.each { |name| skills_context.
|
|
398
|
+
names.each { |name| skills_context.preactivate(name) }
|
|
399
399
|
end
|
|
400
400
|
|
|
401
401
|
skills_context
|
|
@@ -415,7 +415,7 @@ class Riffer::Agent
|
|
|
415
415
|
|
|
416
416
|
skills_config = @config.skills_config
|
|
417
417
|
|
|
418
|
-
if skills_config
|
|
418
|
+
if skills_config && @context.skills&.activatable?
|
|
419
419
|
skill_activate_tool_class = skills_config.activate_tool || Riffer.config.skills.default_activate_tool
|
|
420
420
|
|
|
421
421
|
if tools.any? { |t| t.name == skill_activate_tool_class.name }
|
data/lib/riffer/config.rb
CHANGED
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
|
|
4
4
|
# Configuration for the Riffer framework.
|
|
5
5
|
class Riffer::Config
|
|
6
|
-
AmazonBedrock = Struct.new(:api_token, :region
|
|
7
|
-
Anthropic = Struct.new(:api_key
|
|
8
|
-
AzureOpenAI = Struct.new(:api_key, :endpoint
|
|
9
|
-
Gemini = Struct.new(:api_key, :open_timeout, :read_timeout
|
|
10
|
-
OpenAI = Struct.new(:api_key
|
|
11
|
-
OpenRouter = Struct.new(:api_key
|
|
12
|
-
Evals = Struct.new(:judge_model
|
|
13
|
-
Mcp = Struct.new(:credentials, :discovery_runner
|
|
6
|
+
AmazonBedrock = Struct.new(:api_token, :region)
|
|
7
|
+
Anthropic = Struct.new(:api_key)
|
|
8
|
+
AzureOpenAI = Struct.new(:api_key, :endpoint)
|
|
9
|
+
Gemini = Struct.new(:api_key, :open_timeout, :read_timeout)
|
|
10
|
+
OpenAI = Struct.new(:api_key)
|
|
11
|
+
OpenRouter = Struct.new(:api_key)
|
|
12
|
+
Evals = Struct.new(:judge_model)
|
|
13
|
+
Mcp = Struct.new(:credentials, :discovery_runner)
|
|
14
14
|
|
|
15
15
|
# Skills-related global configuration.
|
|
16
16
|
class Skills
|
|
@@ -49,6 +49,216 @@ class Riffer::Config
|
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
# Tracing-related global configuration.
|
|
53
|
+
class Tracing
|
|
54
|
+
# Whether riffer emits OTEL spans; defaults to +true+, a no-op until a
|
|
55
|
+
# host wires an OTEL SDK.
|
|
56
|
+
attr_reader :enabled #: bool
|
|
57
|
+
|
|
58
|
+
# Whether LLM-call spans capture full message content
|
|
59
|
+
# (<tt>gen_ai.input.messages</tt>, <tt>gen_ai.output.messages</tt>,
|
|
60
|
+
# <tt>gen_ai.system_instructions</tt>); defaults to +false+ — message
|
|
61
|
+
# content routinely carries sensitive data.
|
|
62
|
+
attr_reader :capture_messages #: bool
|
|
63
|
+
|
|
64
|
+
# Explicit OTEL tracer provider; defaults to +nil+, which resolves the
|
|
65
|
+
# global <tt>OpenTelemetry.tracer_provider</tt> at first span.
|
|
66
|
+
attr_reader :tracer_provider #: untyped
|
|
67
|
+
|
|
68
|
+
#--
|
|
69
|
+
#: () -> void
|
|
70
|
+
def initialize
|
|
71
|
+
@enabled = true
|
|
72
|
+
@capture_messages = false
|
|
73
|
+
@tracer_provider = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Sets the enabled flag, coercing boolean-ish values so an env-var
|
|
77
|
+
# +"false"+ (truthy in Ruby) doesn't silently keep tracing on. Raises
|
|
78
|
+
# Riffer::ArgumentError on an unrecognized value.
|
|
79
|
+
#--
|
|
80
|
+
#: (untyped) -> void
|
|
81
|
+
def enabled=(value)
|
|
82
|
+
@enabled = Riffer::Helpers::Boolean.coerce(value, attribute: "enabled")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Sets the capture_messages flag, coercing boolean-ish values so an
|
|
86
|
+
# env-var +"false"+ (truthy in Ruby) doesn't silently enable content
|
|
87
|
+
# capture. Raises Riffer::ArgumentError on an unrecognized value.
|
|
88
|
+
#--
|
|
89
|
+
#: (untyped) -> void
|
|
90
|
+
def capture_messages=(value)
|
|
91
|
+
@capture_messages = Riffer::Helpers::Boolean.coerce(value, attribute: "capture_messages")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Sets an explicit tracer provider, forcing the OTEL backend. Raises
|
|
95
|
+
# Riffer::ArgumentError when the OpenTelemetry API gem isn't available
|
|
96
|
+
# at a supported version.
|
|
97
|
+
#--
|
|
98
|
+
#: (untyped) -> void
|
|
99
|
+
def tracer_provider=(value)
|
|
100
|
+
if !value.nil? && !Riffer::Tracing::Otel.available?
|
|
101
|
+
raise Riffer::ArgumentError,
|
|
102
|
+
"tracer_provider requires the opentelemetry-api gem (#{Riffer::Tracing::Otel::SUPPORTED_API_VERSIONS})"
|
|
103
|
+
end
|
|
104
|
+
@tracer_provider = value
|
|
105
|
+
Riffer::Tracing.reset!
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Metrics-related global configuration, independent of +config.tracing+ so a
|
|
110
|
+
# host can run one signal without the other.
|
|
111
|
+
class Metrics
|
|
112
|
+
# Whether riffer records OTEL metric instruments; defaults to +true+, a
|
|
113
|
+
# no-op until a host wires an OTEL metrics SDK.
|
|
114
|
+
attr_reader :enabled #: bool
|
|
115
|
+
|
|
116
|
+
# Explicit OTEL meter provider; defaults to +nil+, which resolves the
|
|
117
|
+
# global <tt>OpenTelemetry.meter_provider</tt> at first record.
|
|
118
|
+
attr_reader :meter_provider #: untyped
|
|
119
|
+
|
|
120
|
+
#--
|
|
121
|
+
#: () -> void
|
|
122
|
+
def initialize
|
|
123
|
+
@enabled = true
|
|
124
|
+
@meter_provider = nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Sets the enabled flag, coercing boolean-ish values so an env-var
|
|
128
|
+
# +"false"+ (truthy in Ruby) doesn't silently keep metrics on. Raises
|
|
129
|
+
# Riffer::ArgumentError on an unrecognized value.
|
|
130
|
+
#--
|
|
131
|
+
#: (untyped) -> void
|
|
132
|
+
def enabled=(value)
|
|
133
|
+
@enabled = Riffer::Helpers::Boolean.coerce(value, attribute: "enabled")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Sets an explicit meter provider, forcing the OTEL backend. Raises
|
|
137
|
+
# Riffer::ArgumentError when the OpenTelemetry metrics API gem isn't
|
|
138
|
+
# available at a supported version.
|
|
139
|
+
#--
|
|
140
|
+
#: (untyped) -> void
|
|
141
|
+
def meter_provider=(value)
|
|
142
|
+
if !value.nil? && !Riffer::Metrics::Otel.available?
|
|
143
|
+
raise Riffer::ArgumentError,
|
|
144
|
+
"meter_provider requires the opentelemetry-metrics-api gem (#{Riffer::Metrics::Otel::SUPPORTED_API_VERSIONS})"
|
|
145
|
+
end
|
|
146
|
+
@meter_provider = value
|
|
147
|
+
Riffer::Metrics.reset!
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Consumer-configured token pricing, keyed by +provider/model+ id. Riffer
|
|
152
|
+
# ships no price table, so an unconfigured model carries no cost.
|
|
153
|
+
class Pricing
|
|
154
|
+
# @rbs @rates: Hash[String, Riffer::Config::Pricing::Rates]
|
|
155
|
+
|
|
156
|
+
# Per-million-token rates for one model's four token buckets. +cache_read+
|
|
157
|
+
# and +cache_write+ fall back to the +input+ rate when unset.
|
|
158
|
+
class Rates
|
|
159
|
+
# Input rate per million tokens.
|
|
160
|
+
attr_reader :input #: Float
|
|
161
|
+
|
|
162
|
+
# Output rate per million tokens.
|
|
163
|
+
attr_reader :output #: Float
|
|
164
|
+
|
|
165
|
+
# Cache-read rate per million tokens.
|
|
166
|
+
attr_reader :cache_read #: Float?
|
|
167
|
+
|
|
168
|
+
# Cache-write rate per million tokens.
|
|
169
|
+
attr_reader :cache_write #: Float?
|
|
170
|
+
|
|
171
|
+
#--
|
|
172
|
+
#: (input: Float, output: Float, ?cache_read: Float?, ?cache_write: Float?) -> void
|
|
173
|
+
def initialize(input:, output:, cache_read: nil, cache_write: nil)
|
|
174
|
+
@input = input
|
|
175
|
+
@output = output
|
|
176
|
+
@cache_read = cache_read
|
|
177
|
+
@cache_write = cache_write
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Returns the cost for the given token counts.
|
|
181
|
+
#--
|
|
182
|
+
#: (input_tokens: Integer, output_tokens: Integer, ?cache_read_tokens: Integer?, ?cache_write_tokens: Integer?) -> Float
|
|
183
|
+
def cost_for(input_tokens:, output_tokens:, cache_read_tokens: nil, cache_write_tokens: nil)
|
|
184
|
+
read = cache_read_tokens || 0
|
|
185
|
+
write = cache_write_tokens || 0
|
|
186
|
+
uncached = input_tokens - read - write
|
|
187
|
+
uncached = 0 if uncached.negative?
|
|
188
|
+
|
|
189
|
+
per_million = uncached * input +
|
|
190
|
+
read * (cache_read || input) +
|
|
191
|
+
write * (cache_write || input) +
|
|
192
|
+
output_tokens * output
|
|
193
|
+
per_million / 1_000_000.0
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
#--
|
|
198
|
+
#: () -> void
|
|
199
|
+
def initialize
|
|
200
|
+
@rates = {}
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Registers per-million-token rates for a +provider/model+ id/s. Raises
|
|
204
|
+
# Riffer::ArgumentError on a malformed id or a negative/non-numeric rate.
|
|
205
|
+
#--
|
|
206
|
+
#: ((String | Array[String]), input: Numeric, output: Numeric, ?cache_read: Numeric?, ?cache_write: Numeric?) -> void
|
|
207
|
+
def set(models, input:, output:, cache_read: nil, cache_write: nil)
|
|
208
|
+
ids = models.is_a?(Array) ? models : [models]
|
|
209
|
+
raise Riffer::ArgumentError, "at least one model id is required" if ids.empty?
|
|
210
|
+
ids.each { |id| validate_model!(id) }
|
|
211
|
+
|
|
212
|
+
rates = Rates.new(
|
|
213
|
+
input: coerce_rate(input, "input"),
|
|
214
|
+
output: coerce_rate(output, "output"),
|
|
215
|
+
cache_read: coerce_optional_rate(cache_read, "cache_read"),
|
|
216
|
+
cache_write: coerce_optional_rate(cache_write, "cache_write")
|
|
217
|
+
)
|
|
218
|
+
ids.each { |id| @rates[id] = rates }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Returns the rates registered for a +provider/model+ id.
|
|
222
|
+
#--
|
|
223
|
+
#: (String) -> Riffer::Config::Pricing::Rates?
|
|
224
|
+
def rates_for(model)
|
|
225
|
+
@rates[model]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Returns true when no rates are registered.
|
|
229
|
+
#--
|
|
230
|
+
#: () -> bool
|
|
231
|
+
def empty?
|
|
232
|
+
@rates.empty?
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
#--
|
|
238
|
+
#: (String) -> void
|
|
239
|
+
def validate_model!(model)
|
|
240
|
+
segments = model.to_s.split("/", 2)
|
|
241
|
+
valid = segments.length == 2 && segments.none? { |segment| segment.strip.empty? }
|
|
242
|
+
raise Riffer::ArgumentError, "pricing model id must be in \"provider/model\" form, got #{model.inspect}" unless valid
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
#--
|
|
246
|
+
#: (untyped, String) -> Float
|
|
247
|
+
def coerce_rate(value, attribute)
|
|
248
|
+
number = value
|
|
249
|
+
float = value.is_a?(Numeric) ? number.to_f : nil #: Float?
|
|
250
|
+
raise Riffer::ArgumentError, "#{attribute} rate must be a non-negative number, got #{value.inspect}" unless float&.finite? && float >= 0
|
|
251
|
+
float
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
#--
|
|
255
|
+
#: (untyped, String) -> Float?
|
|
256
|
+
def coerce_optional_rate(value, attribute)
|
|
257
|
+
return nil if value.nil?
|
|
258
|
+
coerce_rate(value, attribute)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
52
262
|
VALID_MESSAGE_ID_STRATEGIES = %i[none uuid uuidv7].freeze
|
|
53
263
|
|
|
54
264
|
# Amazon Bedrock configuration.
|
|
@@ -94,6 +304,15 @@ class Riffer::Config
|
|
|
94
304
|
# Skills-related global configuration.
|
|
95
305
|
attr_reader :skills #: Riffer::Config::Skills
|
|
96
306
|
|
|
307
|
+
# Tracing-related global configuration.
|
|
308
|
+
attr_reader :tracing #: Riffer::Config::Tracing
|
|
309
|
+
|
|
310
|
+
# Metrics-related global configuration.
|
|
311
|
+
attr_reader :metrics #: Riffer::Config::Metrics
|
|
312
|
+
|
|
313
|
+
# Consumer-configured per-model token pricing.
|
|
314
|
+
attr_reader :pricing #: Riffer::Config::Pricing
|
|
315
|
+
|
|
97
316
|
# Strategy for auto-generating message ids: +:none+ (default), +:uuid+, or
|
|
98
317
|
# +:uuidv7+. When not +:none+, messages get an +id+ at construction, and
|
|
99
318
|
# seeded messages passed to +Riffer::Agent#generate+ must carry their own.
|
|
@@ -122,13 +341,7 @@ class Riffer::Config
|
|
|
122
341
|
#--
|
|
123
342
|
#: (untyped) -> void
|
|
124
343
|
def experimental_history_healing=(value)
|
|
125
|
-
@experimental_history_healing =
|
|
126
|
-
when true, "true", 1, "1" then true
|
|
127
|
-
when false, "false", 0, "0", nil then false
|
|
128
|
-
else
|
|
129
|
-
raise Riffer::ArgumentError,
|
|
130
|
-
"experimental_history_healing must be a boolean (or 'true'/'false'/'1'/'0'/1/0), got #{value.inspect}"
|
|
131
|
-
end
|
|
344
|
+
@experimental_history_healing = Riffer::Helpers::Boolean.coerce(value, attribute: "experimental_history_healing")
|
|
132
345
|
end
|
|
133
346
|
|
|
134
347
|
#--
|
|
@@ -144,6 +357,9 @@ class Riffer::Config
|
|
|
144
357
|
@mcp = Mcp.new(credentials: nil, discovery_runner: Riffer::Runner::Sequential.new)
|
|
145
358
|
@tool_runtime = Riffer::Tools::Runtime::Inline.new
|
|
146
359
|
@skills = Skills.new
|
|
360
|
+
@tracing = Tracing.new
|
|
361
|
+
@metrics = Metrics.new
|
|
362
|
+
@pricing = Pricing.new
|
|
147
363
|
@message_id_strategy = :none
|
|
148
364
|
@experimental_history_healing = false
|
|
149
365
|
end
|
data/lib/riffer/guardrail.rb
CHANGED
|
@@ -31,6 +31,14 @@ class Riffer::Guardrail
|
|
|
31
31
|
pass(response)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Returns the guardrail's identifier, used as the tracing span suffix and the
|
|
35
|
+
# <tt>riffer.guardrail.name</tt> attribute; override to label the span.
|
|
36
|
+
#--
|
|
37
|
+
#: () -> String
|
|
38
|
+
def name
|
|
39
|
+
Riffer::Helpers::ClassNameConverter.convert(self.class.name)
|
|
40
|
+
end
|
|
41
|
+
|
|
34
42
|
protected
|
|
35
43
|
|
|
36
44
|
# Creates a pass result that continues with unchanged data.
|
|
@@ -81,6 +81,21 @@ class Riffer::Guardrails::Runner
|
|
|
81
81
|
#--
|
|
82
82
|
#: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
|
|
83
83
|
def execute_guardrail(guardrail, data, messages:)
|
|
84
|
+
Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
|
|
85
|
+
result = run_guardrail_phase(guardrail, data, messages: messages)
|
|
86
|
+
record_guardrail_outcome(span, result)
|
|
87
|
+
result
|
|
88
|
+
rescue => error
|
|
89
|
+
# The backend records the exception and error status on the re-raise;
|
|
90
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
91
|
+
span.set_attribute("error.type", error.class.name)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#--
|
|
97
|
+
#: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
|
|
98
|
+
def run_guardrail_phase(guardrail, data, messages:)
|
|
84
99
|
case phase
|
|
85
100
|
when :before
|
|
86
101
|
guardrail.process_input(data, context: context)
|
|
@@ -90,4 +105,22 @@ class Riffer::Guardrails::Runner
|
|
|
90
105
|
raise Riffer::Error, "Unexpected guardrail phase: #{phase}. Valid phases: #{Riffer::Guardrails::PHASES.join(", ")}"
|
|
91
106
|
end
|
|
92
107
|
end
|
|
108
|
+
|
|
109
|
+
#--
|
|
110
|
+
#: (Riffer::Guardrail) -> Hash[String, untyped]
|
|
111
|
+
def guardrail_span_attributes(guardrail)
|
|
112
|
+
{
|
|
113
|
+
"riffer.guardrail.name" => guardrail.name,
|
|
114
|
+
"riffer.guardrail.phase" => phase.to_s
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# A block is a handled outcome, so its span status stays unset — an error
|
|
119
|
+
# span status is reserved for a raised exception.
|
|
120
|
+
#--
|
|
121
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::Null::Span), Riffer::Guardrails::Result) -> void
|
|
122
|
+
def record_guardrail_outcome(span, result)
|
|
123
|
+
span.set_attribute("riffer.guardrail.action", result.type.to_s)
|
|
124
|
+
span.set_attribute("riffer.tripwire.reason", result.data) if result.block?
|
|
125
|
+
end
|
|
93
126
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
# Coercion for boolean-ish configuration values.
|
|
5
|
+
module Riffer::Helpers::Boolean
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
# Coerces +value+ to a boolean so an env-var +"false"+ (truthy in Ruby)
|
|
9
|
+
# doesn't silently read as +true+. Raises Riffer::ArgumentError on an
|
|
10
|
+
# unrecognized value, naming +attribute+ in the message.
|
|
11
|
+
#--
|
|
12
|
+
#: (untyped, attribute: String) -> bool
|
|
13
|
+
def coerce(value, attribute:)
|
|
14
|
+
case value
|
|
15
|
+
when true, "true", 1, "1" then true
|
|
16
|
+
when false, "false", 0, "0", nil then false
|
|
17
|
+
else
|
|
18
|
+
raise Riffer::ArgumentError,
|
|
19
|
+
"#{attribute} must be a boolean (or 'true'/'false'/'1'/'0'/1/0), got #{value.inspect}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|