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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/.release-please-manifest.json +1 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +34 -0
  5. data/README.md +13 -11
  6. data/docs/01_OVERVIEW.md +2 -0
  7. data/docs/04_AGENT_LIFECYCLE.md +15 -13
  8. data/docs/08_MESSAGES.md +39 -5
  9. data/docs/09_STREAM_EVENTS.md +14 -0
  10. data/docs/10_CONFIGURATION.md +73 -4
  11. data/docs/13_SKILLS.md +66 -4
  12. data/docs/14_MCP.md +2 -1
  13. data/docs/16_TRACING.md +250 -0
  14. data/docs/17_METRICS.md +123 -0
  15. data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
  16. data/lib/riffer/agent/response.rb +11 -2
  17. data/lib/riffer/agent/run.rb +136 -35
  18. data/lib/riffer/agent.rb +5 -5
  19. data/lib/riffer/config.rb +231 -15
  20. data/lib/riffer/guardrail.rb +8 -0
  21. data/lib/riffer/guardrails/runner.rb +33 -0
  22. data/lib/riffer/helpers/boolean.rb +22 -0
  23. data/lib/riffer/mcp/authenticated_tool.rb +14 -20
  24. data/lib/riffer/mcp/registration.rb +4 -4
  25. data/lib/riffer/mcp/tool.rb +23 -0
  26. data/lib/riffer/mcp/tool_factory.rb +14 -22
  27. data/lib/riffer/messages/assistant.rb +15 -3
  28. data/lib/riffer/messages/base.rb +2 -1
  29. data/lib/riffer/metrics/instruments.rb +25 -0
  30. data/lib/riffer/metrics/null.rb +14 -0
  31. data/lib/riffer/metrics/otel.rb +79 -0
  32. data/lib/riffer/metrics.rb +93 -0
  33. data/lib/riffer/providers/amazon_bedrock.rb +57 -21
  34. data/lib/riffer/providers/anthropic.rb +59 -24
  35. data/lib/riffer/providers/azure_open_ai.rb +7 -0
  36. data/lib/riffer/providers/base.rb +247 -15
  37. data/lib/riffer/providers/finish_reason.rb +27 -0
  38. data/lib/riffer/providers/gemini.rb +59 -11
  39. data/lib/riffer/providers/mock.rb +30 -9
  40. data/lib/riffer/providers/open_ai.rb +78 -24
  41. data/lib/riffer/providers/open_router.rb +56 -16
  42. data/lib/riffer/providers/repository.rb +9 -0
  43. data/lib/riffer/providers/token_usage.rb +27 -11
  44. data/lib/riffer/skills/activate_tool.rb +12 -2
  45. data/lib/riffer/skills/adapter.rb +15 -0
  46. data/lib/riffer/skills/context.rb +78 -11
  47. data/lib/riffer/skills/frontmatter.rb +13 -5
  48. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  49. data/lib/riffer/skills/xml_adapter.rb +1 -1
  50. data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
  51. data/lib/riffer/tools/runtime.rb +99 -3
  52. data/lib/riffer/tracing/capture.rb +92 -0
  53. data/lib/riffer/tracing/null.rb +61 -0
  54. data/lib/riffer/tracing/otel.rb +131 -0
  55. data/lib/riffer/tracing/stream_recorder.rb +51 -0
  56. data/lib/riffer/tracing.rb +78 -0
  57. data/lib/riffer/version.rb +1 -1
  58. data/sig/_private/opentelemetry.rbs +22 -0
  59. data/sig/generated/riffer/agent/response.rbs +9 -2
  60. data/sig/generated/riffer/agent/run.rbs +28 -8
  61. data/sig/generated/riffer/config.rbs +162 -16
  62. data/sig/generated/riffer/guardrail.rbs +6 -0
  63. data/sig/generated/riffer/guardrails/runner.rbs +14 -0
  64. data/sig/generated/riffer/helpers/boolean.rbs +11 -0
  65. data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
  66. data/sig/generated/riffer/mcp/registration.rbs +4 -4
  67. data/sig/generated/riffer/mcp/tool.rbs +19 -0
  68. data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
  69. data/sig/generated/riffer/messages/assistant.rbs +10 -4
  70. data/sig/generated/riffer/metrics/instruments.rbs +13 -0
  71. data/sig/generated/riffer/metrics/null.rbs +10 -0
  72. data/sig/generated/riffer/metrics/otel.rbs +47 -0
  73. data/sig/generated/riffer/metrics.rbs +71 -0
  74. data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
  75. data/sig/generated/riffer/providers/anthropic.rbs +41 -20
  76. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
  77. data/sig/generated/riffer/providers/base.rbs +78 -2
  78. data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
  79. data/sig/generated/riffer/providers/gemini.rbs +25 -2
  80. data/sig/generated/riffer/providers/mock.rbs +16 -5
  81. data/sig/generated/riffer/providers/open_ai.rbs +44 -22
  82. data/sig/generated/riffer/providers/open_router.rbs +31 -12
  83. data/sig/generated/riffer/providers/repository.rbs +7 -0
  84. data/sig/generated/riffer/providers/token_usage.rbs +20 -10
  85. data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
  86. data/sig/generated/riffer/skills/adapter.rbs +10 -0
  87. data/sig/generated/riffer/skills/context.rbs +52 -4
  88. data/sig/generated/riffer/skills/frontmatter.rbs +10 -3
  89. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
  90. data/sig/generated/riffer/tools/runtime.rbs +35 -0
  91. data/sig/generated/riffer/tracing/capture.rbs +46 -0
  92. data/sig/generated/riffer/tracing/null.rbs +46 -0
  93. data/sig/generated/riffer/tracing/otel.rbs +83 -0
  94. data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
  95. data/sig/generated/riffer/tracing.rbs +52 -0
  96. data/sig/manual/riffer/helpers/boolean.rbs +5 -0
  97. data/sig/manual/riffer/metrics/null.rbs +5 -0
  98. data/sig/manual/riffer/metrics.rbs +5 -0
  99. data/sig/manual/riffer/providers.rbs +9 -0
  100. data/sig/manual/riffer/tracing/capture.rbs +5 -0
  101. data/sig/manual/riffer/tracing/null.rbs +5 -0
  102. data/sig/manual/riffer/tracing.rbs +5 -0
  103. metadata +40 -4
@@ -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
- Enumerator.new { |stream_yielder| run_loop(agent, stream_yielder: stream_yielder) }
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) { |tripped| return tripped }
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) { stream_yielder << Riffer::StreamEvents::SkillActivation.new(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
- step = agent.session.steps
83
+ begin
84
+ step = agent.session.steps
45
85
 
46
- reason = catch(:riffer_interrupt) do
47
- execute_pending_tool_calls(agent)
86
+ reason = catch(:riffer_interrupt) do
87
+ execute_pending_tool_calls(agent)
48
88
 
49
- loop do
50
- response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
51
- step += 1
52
- track_token_usage(agent, response.token_usage)
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
- processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) { |tripped| return tripped }
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
- agent.session.add(processed_response)
100
+ agent.session.add(processed_response)
57
101
 
58
- break unless processed_response.has_tool_calls?
102
+ break unless processed_response.has_tool_calls?
59
103
 
60
- max_steps = agent.config.max_steps
61
- throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
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
- execute_tool_calls(agent, processed_response)
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
- return final_response(agent, all_modifications)
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::Agent::Response) -> void } -> void
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 tripwire_response(agent, stream_yielder, tripwire, all_modifications) if tripwire
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::Agent::Response) -> void } -> untyped
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 tripwire_response(agent, stream_yielder, tripwire, all_modifications) if tripwire
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
- current = agent.context.token_usage
268
- agent.context.token_usage = current ? current + usage : usage
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
- skills = @context.skills
347
- return nil unless skills&.system_prompt
348
- Riffer::Messages::System.new(skills.system_prompt)
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.activate(name) }
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, keyword_init: true)
7
- Anthropic = Struct.new(:api_key, keyword_init: true)
8
- AzureOpenAI = Struct.new(:api_key, :endpoint, keyword_init: true)
9
- Gemini = Struct.new(:api_key, :open_timeout, :read_timeout, keyword_init: true)
10
- OpenAI = Struct.new(:api_key, keyword_init: true)
11
- OpenRouter = Struct.new(:api_key, keyword_init: true)
12
- Evals = Struct.new(:judge_model, keyword_init: true)
13
- Mcp = Struct.new(:credentials, :discovery_runner, keyword_init: true)
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 = case value
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
@@ -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