riffer 0.32.1 → 0.34.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 +39 -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 +62 -4
- data/docs/14_MCP.md +2 -1
- data/docs/16_TRACING.md +274 -0
- data/docs/17_METRICS.md +153 -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 +1 -1
- data/lib/riffer/config.rb +230 -15
- data/lib/riffer/guardrail.rb +8 -0
- data/lib/riffer/guardrails/runner.rb +58 -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 +31 -0
- data/lib/riffer/metrics/no_op.rb +14 -0
- data/lib/riffer/metrics/otel.rb +80 -0
- data/lib/riffer/metrics.rb +94 -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 +11 -2
- data/lib/riffer/skills/adapter.rb +15 -0
- data/lib/riffer/skills/context.rb +63 -11
- 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/no_op.rb +61 -0
- data/lib/riffer/tracing/otel.rb +132 -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 +22 -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 +15 -0
- data/sig/generated/riffer/metrics/no_op.rbs +10 -0
- data/sig/generated/riffer/metrics/otel.rbs +48 -0
- data/sig/generated/riffer/metrics.rbs +72 -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 +42 -4
- 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/no_op.rbs +46 -0
- data/sig/generated/riffer/tracing/otel.rbs +84 -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/no_op.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/no_op.rbs +5 -0
- data/sig/manual/riffer/tracing.rbs +5 -0
- metadata +42 -6
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::NoOp::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
|
@@ -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
|
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,215 @@ 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
|
+
# The backend riffer routes spans through; defaults to +nil+, a no-op.
|
|
65
|
+
# Riffer auto-detects no backend; assigning one is opt-in.
|
|
66
|
+
attr_reader :backend #: untyped
|
|
67
|
+
|
|
68
|
+
#--
|
|
69
|
+
#: () -> void
|
|
70
|
+
def initialize
|
|
71
|
+
@enabled = true
|
|
72
|
+
@capture_messages = false
|
|
73
|
+
@backend = 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 the tracing backend riffer routes spans through. Raises
|
|
95
|
+
# Riffer::ArgumentError unless the value is +nil+ or responds to the full
|
|
96
|
+
# delegated contract: +in_span+, +current_context+, and +with_context+.
|
|
97
|
+
#--
|
|
98
|
+
#: (untyped) -> void
|
|
99
|
+
def backend=(value)
|
|
100
|
+
contract = %i[in_span current_context with_context]
|
|
101
|
+
unless value.nil? || contract.all? { |method| value.respond_to?(method) }
|
|
102
|
+
raise Riffer::ArgumentError, "tracing backend must respond to #in_span, #current_context, and #with_context"
|
|
103
|
+
end
|
|
104
|
+
@backend = 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
|
+
# The backend riffer routes measurements through; defaults to +nil+, a no-op.
|
|
117
|
+
# Riffer auto-detects no backend; assigning one is opt-in.
|
|
118
|
+
attr_reader :backend #: untyped
|
|
119
|
+
|
|
120
|
+
#--
|
|
121
|
+
#: () -> void
|
|
122
|
+
def initialize
|
|
123
|
+
@enabled = true
|
|
124
|
+
@backend = 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 the metrics backend riffer routes measurements through. Raises
|
|
137
|
+
# Riffer::ArgumentError unless the value is +nil+ or responds to
|
|
138
|
+
# +record_histogram+.
|
|
139
|
+
#--
|
|
140
|
+
#: (untyped) -> void
|
|
141
|
+
def backend=(value)
|
|
142
|
+
unless value.nil? || value.respond_to?(:record_histogram)
|
|
143
|
+
raise Riffer::ArgumentError, "metrics backend must respond to #record_histogram"
|
|
144
|
+
end
|
|
145
|
+
@backend = value
|
|
146
|
+
Riffer::Metrics.reset!
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Consumer-configured token pricing, keyed by +provider/model+ id. Riffer
|
|
151
|
+
# ships no price table, so an unconfigured model carries no cost.
|
|
152
|
+
class Pricing
|
|
153
|
+
# @rbs @rates: Hash[String, Riffer::Config::Pricing::Rates]
|
|
154
|
+
|
|
155
|
+
# Per-million-token rates for one model's four token buckets. +cache_read+
|
|
156
|
+
# and +cache_write+ fall back to the +input+ rate when unset.
|
|
157
|
+
class Rates
|
|
158
|
+
# Input rate per million tokens.
|
|
159
|
+
attr_reader :input #: Float
|
|
160
|
+
|
|
161
|
+
# Output rate per million tokens.
|
|
162
|
+
attr_reader :output #: Float
|
|
163
|
+
|
|
164
|
+
# Cache-read rate per million tokens.
|
|
165
|
+
attr_reader :cache_read #: Float?
|
|
166
|
+
|
|
167
|
+
# Cache-write rate per million tokens.
|
|
168
|
+
attr_reader :cache_write #: Float?
|
|
169
|
+
|
|
170
|
+
#--
|
|
171
|
+
#: (input: Float, output: Float, ?cache_read: Float?, ?cache_write: Float?) -> void
|
|
172
|
+
def initialize(input:, output:, cache_read: nil, cache_write: nil)
|
|
173
|
+
@input = input
|
|
174
|
+
@output = output
|
|
175
|
+
@cache_read = cache_read
|
|
176
|
+
@cache_write = cache_write
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Returns the cost for the given token counts.
|
|
180
|
+
#--
|
|
181
|
+
#: (input_tokens: Integer, output_tokens: Integer, ?cache_read_tokens: Integer?, ?cache_write_tokens: Integer?) -> Float
|
|
182
|
+
def cost_for(input_tokens:, output_tokens:, cache_read_tokens: nil, cache_write_tokens: nil)
|
|
183
|
+
read = cache_read_tokens || 0
|
|
184
|
+
write = cache_write_tokens || 0
|
|
185
|
+
uncached = input_tokens - read - write
|
|
186
|
+
uncached = 0 if uncached.negative?
|
|
187
|
+
|
|
188
|
+
per_million = uncached * input +
|
|
189
|
+
read * (cache_read || input) +
|
|
190
|
+
write * (cache_write || input) +
|
|
191
|
+
output_tokens * output
|
|
192
|
+
per_million / 1_000_000.0
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
#--
|
|
197
|
+
#: () -> void
|
|
198
|
+
def initialize
|
|
199
|
+
@rates = {}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Registers per-million-token rates for a +provider/model+ id/s. Raises
|
|
203
|
+
# Riffer::ArgumentError on a malformed id or a negative/non-numeric rate.
|
|
204
|
+
#--
|
|
205
|
+
#: ((String | Array[String]), input: Numeric, output: Numeric, ?cache_read: Numeric?, ?cache_write: Numeric?) -> void
|
|
206
|
+
def set(models, input:, output:, cache_read: nil, cache_write: nil)
|
|
207
|
+
ids = models.is_a?(Array) ? models : [models]
|
|
208
|
+
raise Riffer::ArgumentError, "at least one model id is required" if ids.empty?
|
|
209
|
+
ids.each { |id| validate_model!(id) }
|
|
210
|
+
|
|
211
|
+
rates = Rates.new(
|
|
212
|
+
input: coerce_rate(input, "input"),
|
|
213
|
+
output: coerce_rate(output, "output"),
|
|
214
|
+
cache_read: coerce_optional_rate(cache_read, "cache_read"),
|
|
215
|
+
cache_write: coerce_optional_rate(cache_write, "cache_write")
|
|
216
|
+
)
|
|
217
|
+
ids.each { |id| @rates[id] = rates }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Returns the rates registered for a +provider/model+ id.
|
|
221
|
+
#--
|
|
222
|
+
#: (String) -> Riffer::Config::Pricing::Rates?
|
|
223
|
+
def rates_for(model)
|
|
224
|
+
@rates[model]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Returns true when no rates are registered.
|
|
228
|
+
#--
|
|
229
|
+
#: () -> bool
|
|
230
|
+
def empty?
|
|
231
|
+
@rates.empty?
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private
|
|
235
|
+
|
|
236
|
+
#--
|
|
237
|
+
#: (String) -> void
|
|
238
|
+
def validate_model!(model)
|
|
239
|
+
segments = model.to_s.split("/", 2)
|
|
240
|
+
valid = segments.length == 2 && segments.none? { |segment| segment.strip.empty? }
|
|
241
|
+
raise Riffer::ArgumentError, "pricing model id must be in \"provider/model\" form, got #{model.inspect}" unless valid
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#--
|
|
245
|
+
#: (untyped, String) -> Float
|
|
246
|
+
def coerce_rate(value, attribute)
|
|
247
|
+
number = value
|
|
248
|
+
float = value.is_a?(Numeric) ? number.to_f : nil #: Float?
|
|
249
|
+
raise Riffer::ArgumentError, "#{attribute} rate must be a non-negative number, got #{value.inspect}" unless float&.finite? && float >= 0
|
|
250
|
+
float
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
#--
|
|
254
|
+
#: (untyped, String) -> Float?
|
|
255
|
+
def coerce_optional_rate(value, attribute)
|
|
256
|
+
return nil if value.nil?
|
|
257
|
+
coerce_rate(value, attribute)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
52
261
|
VALID_MESSAGE_ID_STRATEGIES = %i[none uuid uuidv7].freeze
|
|
53
262
|
|
|
54
263
|
# Amazon Bedrock configuration.
|
|
@@ -94,6 +303,15 @@ class Riffer::Config
|
|
|
94
303
|
# Skills-related global configuration.
|
|
95
304
|
attr_reader :skills #: Riffer::Config::Skills
|
|
96
305
|
|
|
306
|
+
# Tracing-related global configuration.
|
|
307
|
+
attr_reader :tracing #: Riffer::Config::Tracing
|
|
308
|
+
|
|
309
|
+
# Metrics-related global configuration.
|
|
310
|
+
attr_reader :metrics #: Riffer::Config::Metrics
|
|
311
|
+
|
|
312
|
+
# Consumer-configured per-model token pricing.
|
|
313
|
+
attr_reader :pricing #: Riffer::Config::Pricing
|
|
314
|
+
|
|
97
315
|
# Strategy for auto-generating message ids: +:none+ (default), +:uuid+, or
|
|
98
316
|
# +:uuidv7+. When not +:none+, messages get an +id+ at construction, and
|
|
99
317
|
# seeded messages passed to +Riffer::Agent#generate+ must carry their own.
|
|
@@ -122,13 +340,7 @@ class Riffer::Config
|
|
|
122
340
|
#--
|
|
123
341
|
#: (untyped) -> void
|
|
124
342
|
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
|
|
343
|
+
@experimental_history_healing = Riffer::Helpers::Boolean.coerce(value, attribute: "experimental_history_healing")
|
|
132
344
|
end
|
|
133
345
|
|
|
134
346
|
#--
|
|
@@ -144,6 +356,9 @@ class Riffer::Config
|
|
|
144
356
|
@mcp = Mcp.new(credentials: nil, discovery_runner: Riffer::Runner::Sequential.new)
|
|
145
357
|
@tool_runtime = Riffer::Tools::Runtime::Inline.new
|
|
146
358
|
@skills = Skills.new
|
|
359
|
+
@tracing = Tracing.new
|
|
360
|
+
@metrics = Metrics.new
|
|
361
|
+
@pricing = Pricing.new
|
|
147
362
|
@message_id_strategy = :none
|
|
148
363
|
@experimental_history_healing = false
|
|
149
364
|
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,38 @@ 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
|
+
instrument_guardrail(guardrail) do
|
|
85
|
+
Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
|
|
86
|
+
result = run_guardrail_phase(guardrail, data, messages: messages)
|
|
87
|
+
record_guardrail_outcome(span, result)
|
|
88
|
+
result
|
|
89
|
+
rescue => error
|
|
90
|
+
# The backend records the exception and error status on the re-raise;
|
|
91
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
92
|
+
span.set_attribute("error.type", error.class.name)
|
|
93
|
+
raise
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#--
|
|
99
|
+
#: (Riffer::Guardrail) { () -> Riffer::Guardrails::Result } -> Riffer::Guardrails::Result
|
|
100
|
+
def instrument_guardrail(guardrail)
|
|
101
|
+
start = Riffer::Metrics.monotonic_now
|
|
102
|
+
error_type = nil #: String?
|
|
103
|
+
begin
|
|
104
|
+
yield
|
|
105
|
+
rescue => error
|
|
106
|
+
error_type = error.class.name #: String?
|
|
107
|
+
raise
|
|
108
|
+
ensure
|
|
109
|
+
Riffer::Metrics::Instruments::GUARDRAIL_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: guardrail_metric_attributes(guardrail, error_type))
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
#--
|
|
114
|
+
#: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
|
|
115
|
+
def run_guardrail_phase(guardrail, data, messages:)
|
|
84
116
|
case phase
|
|
85
117
|
when :before
|
|
86
118
|
guardrail.process_input(data, context: context)
|
|
@@ -90,4 +122,30 @@ class Riffer::Guardrails::Runner
|
|
|
90
122
|
raise Riffer::Error, "Unexpected guardrail phase: #{phase}. Valid phases: #{Riffer::Guardrails::PHASES.join(", ")}"
|
|
91
123
|
end
|
|
92
124
|
end
|
|
125
|
+
|
|
126
|
+
#--
|
|
127
|
+
#: (Riffer::Guardrail) -> Hash[String, untyped]
|
|
128
|
+
def guardrail_span_attributes(guardrail)
|
|
129
|
+
{
|
|
130
|
+
"riffer.guardrail.name" => guardrail.name,
|
|
131
|
+
"riffer.guardrail.phase" => phase.to_s
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#--
|
|
136
|
+
#: (Riffer::Guardrail, String?) -> Hash[String, untyped]
|
|
137
|
+
def guardrail_metric_attributes(guardrail, error_type)
|
|
138
|
+
attributes = guardrail_span_attributes(guardrail)
|
|
139
|
+
attributes["error.type"] = error_type if error_type
|
|
140
|
+
attributes
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A block is a handled outcome, so its span status stays unset — an error
|
|
144
|
+
# span status is reserved for a raised exception.
|
|
145
|
+
#--
|
|
146
|
+
#: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Guardrails::Result) -> void
|
|
147
|
+
def record_guardrail_outcome(span, result)
|
|
148
|
+
span.set_attribute("riffer.guardrail.action", result.type.to_s)
|
|
149
|
+
span.set_attribute("riffer.tripwire.reason", result.data) if result.block?
|
|
150
|
+
end
|
|
93
151
|
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
|