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.
Files changed (101) 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 +39 -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 +62 -4
  12. data/docs/14_MCP.md +2 -1
  13. data/docs/16_TRACING.md +274 -0
  14. data/docs/17_METRICS.md +153 -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 +1 -1
  19. data/lib/riffer/config.rb +230 -15
  20. data/lib/riffer/guardrail.rb +8 -0
  21. data/lib/riffer/guardrails/runner.rb +58 -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 +31 -0
  30. data/lib/riffer/metrics/no_op.rb +14 -0
  31. data/lib/riffer/metrics/otel.rb +80 -0
  32. data/lib/riffer/metrics.rb +94 -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 +11 -2
  45. data/lib/riffer/skills/adapter.rb +15 -0
  46. data/lib/riffer/skills/context.rb +63 -11
  47. data/lib/riffer/skills/markdown_adapter.rb +1 -1
  48. data/lib/riffer/skills/xml_adapter.rb +1 -1
  49. data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
  50. data/lib/riffer/tools/runtime.rb +99 -3
  51. data/lib/riffer/tracing/capture.rb +92 -0
  52. data/lib/riffer/tracing/no_op.rb +61 -0
  53. data/lib/riffer/tracing/otel.rb +132 -0
  54. data/lib/riffer/tracing/stream_recorder.rb +51 -0
  55. data/lib/riffer/tracing.rb +78 -0
  56. data/lib/riffer/version.rb +1 -1
  57. data/sig/_private/opentelemetry.rbs +22 -0
  58. data/sig/generated/riffer/agent/response.rbs +9 -2
  59. data/sig/generated/riffer/agent/run.rbs +28 -8
  60. data/sig/generated/riffer/config.rbs +162 -16
  61. data/sig/generated/riffer/guardrail.rbs +6 -0
  62. data/sig/generated/riffer/guardrails/runner.rbs +22 -0
  63. data/sig/generated/riffer/helpers/boolean.rbs +11 -0
  64. data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
  65. data/sig/generated/riffer/mcp/registration.rbs +4 -4
  66. data/sig/generated/riffer/mcp/tool.rbs +19 -0
  67. data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
  68. data/sig/generated/riffer/messages/assistant.rbs +10 -4
  69. data/sig/generated/riffer/metrics/instruments.rbs +15 -0
  70. data/sig/generated/riffer/metrics/no_op.rbs +10 -0
  71. data/sig/generated/riffer/metrics/otel.rbs +48 -0
  72. data/sig/generated/riffer/metrics.rbs +72 -0
  73. data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
  74. data/sig/generated/riffer/providers/anthropic.rbs +41 -20
  75. data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
  76. data/sig/generated/riffer/providers/base.rbs +78 -2
  77. data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
  78. data/sig/generated/riffer/providers/gemini.rbs +25 -2
  79. data/sig/generated/riffer/providers/mock.rbs +16 -5
  80. data/sig/generated/riffer/providers/open_ai.rbs +44 -22
  81. data/sig/generated/riffer/providers/open_router.rbs +31 -12
  82. data/sig/generated/riffer/providers/repository.rbs +7 -0
  83. data/sig/generated/riffer/providers/token_usage.rbs +20 -10
  84. data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
  85. data/sig/generated/riffer/skills/adapter.rbs +10 -0
  86. data/sig/generated/riffer/skills/context.rbs +42 -4
  87. data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
  88. data/sig/generated/riffer/tools/runtime.rbs +35 -0
  89. data/sig/generated/riffer/tracing/capture.rbs +46 -0
  90. data/sig/generated/riffer/tracing/no_op.rbs +46 -0
  91. data/sig/generated/riffer/tracing/otel.rbs +84 -0
  92. data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
  93. data/sig/generated/riffer/tracing.rbs +52 -0
  94. data/sig/manual/riffer/helpers/boolean.rbs +5 -0
  95. data/sig/manual/riffer/metrics/no_op.rbs +5 -0
  96. data/sig/manual/riffer/metrics.rbs +5 -0
  97. data/sig/manual/riffer/providers.rbs +9 -0
  98. data/sig/manual/riffer/tracing/capture.rbs +5 -0
  99. data/sig/manual/riffer/tracing/no_op.rbs +5 -0
  100. data/sig/manual/riffer/tracing.rbs +5 -0
  101. metadata +42 -6
@@ -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::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.activate(name) }
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, 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,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 = 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
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
@@ -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