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
@@ -2,11 +2,13 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  # Skills context for an agent generation cycle — coordinates discovery,
5
- # activation, and prompt rendering, caching activations to avoid redundant
5
+ # activation, and prompt rendering, caching skill bodies to avoid redundant
6
6
  # backend reads. Exposed to tools via <tt>context.skills</tt>.
7
7
  class Riffer::Skills::Context
8
8
  # @rbs @backend: Riffer::Skills::Backend
9
- # @rbs @activated: Hash[String, String]
9
+ # @rbs @bodies: Hash[String, String]
10
+ # @rbs @activated: Array[String]
11
+ # @rbs @preactivated: Array[String]
10
12
 
11
13
  # Skill catalog indexed by name.
12
14
  attr_reader :skills #: Hash[String, Riffer::Skills::Frontmatter]
@@ -15,7 +17,7 @@ class Riffer::Skills::Context
15
17
  attr_reader :adapter #: Riffer::Skills::Adapter
16
18
 
17
19
  # Optional callback invoked when a skill is first activated.
18
- attr_writer :on_activate #: (^(String) -> void)?
20
+ attr_accessor :on_activate #: (^(String) -> void)?
19
21
 
20
22
  #--
21
23
  #: (backend: Riffer::Skills::Backend, skills: Hash[String, Riffer::Skills::Frontmatter], adapter: Riffer::Skills::Adapter) -> void
@@ -23,7 +25,20 @@ class Riffer::Skills::Context
23
25
  @backend = backend
24
26
  @skills = skills
25
27
  @adapter = adapter
26
- @activated = {} #: Hash[String, String]
28
+ @bodies = {} #: Hash[String, String]
29
+ @activated = [] #: Array[String]
30
+ @preactivated = [] #: Array[String]
31
+ end
32
+
33
+ # Returns a skill's body without recording an activation.
34
+ #
35
+ # Raises Riffer::ArgumentError if the skill is not in the catalog.
36
+ #
37
+ #--
38
+ #: (String) -> String
39
+ def read(name)
40
+ raise Riffer::ArgumentError, "Unknown skill: '#{name}'" unless skills.key?(name)
41
+ @bodies[name] ||= @backend.read_skill(name)
27
42
  end
28
43
 
29
44
  # Activates a skill by name. Returns the cached body on re-activation.
@@ -33,11 +48,48 @@ class Riffer::Skills::Context
33
48
  #--
34
49
  #: (String) -> String
35
50
  def activate(name)
51
+ body = read(name)
52
+ unless @activated.include?(name)
53
+ @activated << name
54
+ @on_activate&.call(name)
55
+ end
56
+ body
57
+ end
58
+
59
+ # Activates a skill and returns its body wrapped for injection as a user
60
+ # message.
61
+ #
62
+ # Raises Riffer::ArgumentError if the skill is not in the catalog.
63
+ #
64
+ #--
65
+ #: (String) -> String
66
+ def activation_prompt(name)
67
+ body = activate(name)
68
+ @adapter.render_activation(skills.fetch(name), body)
69
+ end
70
+
71
+ # Activates a skill whose body renders in the system prompt rather than the
72
+ # conversation.
73
+ #
74
+ # Raises Riffer::ArgumentError if the skill is not in the catalog.
75
+ #
76
+ #--
77
+ #: (String) -> void
78
+ def preactivate(name)
79
+ activate(name)
80
+ @preactivated << name unless @preactivated.include?(name)
81
+ end
82
+
83
+ # Clears a skill's activation so the next activation is treated as the first.
84
+ #
85
+ # Raises Riffer::ArgumentError if the skill is not in the catalog.
86
+ #
87
+ #--
88
+ #: (String) -> void
89
+ def deactivate(name)
36
90
  raise Riffer::ArgumentError, "Unknown skill: '#{name}'" unless skills.key?(name)
37
- return @activated[name] if @activated.key?(name)
38
- @activated[name] = @backend.read_skill(name)
39
- @on_activate&.call(name)
40
- @activated[name]
91
+ @activated.delete(name)
92
+ nil
41
93
  end
42
94
 
43
95
  # Returns whether a skill has been activated.
@@ -45,7 +97,7 @@ class Riffer::Skills::Context
45
97
  #--
46
98
  #: (String) -> bool
47
99
  def activated?(name)
48
- @activated.key?(name)
100
+ @activated.include?(name)
49
101
  end
50
102
 
51
103
  # Returns whether a skill exists and may be activated by the model.
@@ -71,7 +123,7 @@ class Riffer::Skills::Context
71
123
  available = available_skills
72
124
  parts = [] #: Array[String]
73
125
  parts << @adapter.render_catalog(available) unless available.empty?
74
- @activated.each_value { |body| parts << body }
126
+ @preactivated.each { |name| parts << @adapter.render_activation(skills.fetch(name), @bodies.fetch(name)) }
75
127
  parts.join("\n\n")
76
128
  end
77
129
 
@@ -80,6 +132,6 @@ class Riffer::Skills::Context
80
132
  #--
81
133
  #: () -> Array[Riffer::Skills::Frontmatter]
82
134
  def available_skills
83
- skills.values.reject { |skill| @activated.key?(skill.name) || skill.disable_model_invocation }
135
+ skills.values.reject { |skill| @preactivated.include?(skill.name) || skill.disable_model_invocation }
84
136
  end
85
137
  end
@@ -11,7 +11,7 @@ class Riffer::Skills::MarkdownAdapter < Riffer::Skills::Adapter
11
11
  lines = [] #: Array[String]
12
12
  lines << "## Available Skills"
13
13
  lines << ""
14
- lines << "When a user's request matches a skill description below, call the `#{skill_activate_tool.name}` tool with the skill name. After activation, follow the skill's instructions."
14
+ lines << catalog_instructions
15
15
  lines << ""
16
16
  skills.each do |skill|
17
17
  lines << "- **#{skill.name}**: #{skill.description}"
@@ -11,7 +11,7 @@ class Riffer::Skills::XmlAdapter < Riffer::Skills::Adapter
11
11
  #: (Array[Riffer::Skills::Frontmatter]) -> String
12
12
  def render_catalog(skills)
13
13
  lines = [] #: Array[String]
14
- lines << "When a user's request matches a skill description below, call the `#{skill_activate_tool.name}` tool with the skill name. After activation, follow the skill's instructions."
14
+ lines << catalog_instructions
15
15
  lines << ""
16
16
  lines << "<available_skills>"
17
17
  skills.each do |skill|
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Normalized reason the LLM finished, emitted once near the end of the
5
+ # stream; no ordering guarantee relative to TokenUsageDone.
6
+ class Riffer::StreamEvents::FinishReasonDone < Riffer::StreamEvents::Base
7
+ # The normalized finish reason (see <tt>Riffer::Providers::FinishReason::VALUES</tt>).
8
+ attr_reader :finish_reason #: Symbol
9
+
10
+ # The provider's raw finish-reason value, when one exists on the wire.
11
+ attr_reader :raw_finish_reason #: String?
12
+
13
+ # Raises Riffer::ArgumentError when +finish_reason+ is outside the
14
+ # normalized vocabulary.
15
+ #--
16
+ #: (finish_reason: Symbol, ?raw_finish_reason: String?, ?role: Symbol) -> void
17
+ def initialize(finish_reason:, raw_finish_reason: nil, role: :assistant)
18
+ unless Riffer::Providers::FinishReason::VALUES.include?(finish_reason)
19
+ raise Riffer::ArgumentError, "finish_reason must be one of #{Riffer::Providers::FinishReason::VALUES.inspect}, got #{finish_reason.inspect}"
20
+ end
21
+
22
+ super(role: role)
23
+ @finish_reason = finish_reason
24
+ @raw_finish_reason = raw_finish_reason
25
+ end
26
+
27
+ #--
28
+ #: () -> Hash[Symbol, untyped]
29
+ def to_h
30
+ hash = {role: @role, finish_reason: @finish_reason} #: Hash[Symbol, untyped]
31
+ hash[:raw_finish_reason] = @raw_finish_reason if @raw_finish_reason
32
+ hash
33
+ end
34
+ end
@@ -20,11 +20,17 @@ class Riffer::Tools::Runtime
20
20
  #--
21
21
  #: (Array[Riffer::Messages::Assistant::ToolCall], tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Array[[Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]]
22
22
  def execute(tool_calls, tools:, context:, assistant_message: nil)
23
+ # Each Runner worker runs in its own thread/fiber, where the OTEL context
24
+ # starts empty — capture here so the execute_tool span parents correctly.
25
+ trace_context = Riffer::Tracing.current_context
23
26
  @runner.map(tool_calls, context: context) do |tool_call|
24
- result = around_tool_call(tool_call, context: context, assistant_message: assistant_message) do
25
- dispatch_tool_call(tool_call, tools: tools, context: context, assistant_message: assistant_message)
27
+ Riffer::Tracing.with_context(trace_context) do
28
+ instrument_tool_call(tool_call) do
29
+ around_tool_call(tool_call, context: context, assistant_message: assistant_message) do
30
+ dispatch_tool_call(tool_call, tools: tools, context: context, assistant_message: assistant_message)
31
+ end
32
+ end
26
33
  end
27
- [tool_call, result]
28
34
  end
29
35
  end
30
36
 
@@ -50,6 +56,27 @@ class Riffer::Tools::Runtime
50
56
 
51
57
  private
52
58
 
59
+ #--
60
+ #: (Riffer::Messages::Assistant::ToolCall) { () -> Riffer::Tools::Response } -> [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
61
+ def instrument_tool_call(tool_call)
62
+ start = Riffer::Metrics.monotonic_now
63
+ error_type = nil #: String?
64
+ begin
65
+ result = in_tool_span(tool_call) do |span|
66
+ response = yield
67
+ record_tool_outcome(span, response)
68
+ response
69
+ end
70
+ error_type = result.error_type&.to_s
71
+ [tool_call, result] #: [Riffer::Messages::Assistant::ToolCall, Riffer::Tools::Response]
72
+ rescue => error
73
+ error_type = error.class.name #: String?
74
+ raise
75
+ ensure
76
+ Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: tool_metric_attributes(tool_call, error_type))
77
+ end
78
+ end
79
+
53
80
  #--
54
81
  #: (Riffer::Messages::Assistant::ToolCall, tools: Array[singleton(Riffer::Tool)], context: Riffer::Agent::Context?, ?assistant_message: Riffer::Messages::Assistant?) -> Riffer::Tools::Response
55
82
  def dispatch_tool_call(tool_call, tools:, context:, assistant_message: nil)
@@ -83,4 +110,73 @@ class Riffer::Tools::Runtime
83
110
 
84
111
  JSON.parse(arguments, symbolize_names: true)
85
112
  end
113
+
114
+ # Emitted outside +around_tool_call+ so host enrichment spans nest beneath it.
115
+ #--
116
+ #: [R] (Riffer::Messages::Assistant::ToolCall) { ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> R } -> R
117
+ def in_tool_span(tool_call)
118
+ Riffer::Tracing.in_span("execute_tool #{tool_call.name}", attributes: tool_span_attributes(tool_call), kind: :internal) do |span|
119
+ capture_tool_arguments(span, tool_call)
120
+ yield span
121
+ rescue => error
122
+ # The backend records the exception and error status on the re-raise;
123
+ # error.type is the one semconv attribute it doesn't set.
124
+ span.set_attribute("error.type", error.class.name)
125
+ raise
126
+ end
127
+ end
128
+
129
+ #--
130
+ #: (Riffer::Messages::Assistant::ToolCall) -> Hash[String, untyped]
131
+ def tool_span_attributes(tool_call)
132
+ {
133
+ "gen_ai.operation.name" => "execute_tool",
134
+ "gen_ai.tool.name" => tool_call.name,
135
+ "gen_ai.tool.call.id" => tool_call.call_id
136
+ }
137
+ end
138
+
139
+ #--
140
+ #: (Riffer::Messages::Assistant::ToolCall, String?) -> Hash[String, untyped]
141
+ def tool_metric_attributes(tool_call, error_type)
142
+ attributes = {
143
+ "gen_ai.operation.name" => "execute_tool",
144
+ "gen_ai.tool.name" => tool_call.name
145
+ } #: Hash[String, untyped]
146
+ attributes["error.type"] = error_type if error_type
147
+ attributes
148
+ end
149
+
150
+ # A returned error Response is a handled outcome, so its status stays unset —
151
+ # an error span status is reserved for a raised exception.
152
+ #--
153
+ #: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tools::Response) -> void
154
+ def record_tool_outcome(span, result)
155
+ error_type = result.error_type
156
+ span.set_attribute("error.type", error_type.to_s) if error_type
157
+ capture_tool_result(span, result)
158
+ end
159
+
160
+ #--
161
+ #: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Messages::Assistant::ToolCall) -> void
162
+ def capture_tool_arguments(span, tool_call)
163
+ return unless capture_tool_content?(span)
164
+
165
+ arguments = tool_call.arguments
166
+ span.set_attribute("gen_ai.tool.call.arguments", arguments) if arguments
167
+ end
168
+
169
+ #--
170
+ #: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span), Riffer::Tools::Response) -> void
171
+ def capture_tool_result(span, result)
172
+ return unless capture_tool_content?(span)
173
+
174
+ span.set_attribute("gen_ai.tool.call.result", result.content)
175
+ end
176
+
177
+ #--
178
+ #: ((Riffer::Tracing::Otel::Span | Riffer::Tracing::NoOp::Span)) -> bool
179
+ def capture_tool_content?(span)
180
+ Riffer.config.tracing.capture_messages && span.recording?
181
+ end
86
182
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ require "json"
5
+
6
+ # Serializes riffer messages into the GenAI semconv JSON message structure
7
+ # for opt-in span content capture. File parts become metadata-only stubs —
8
+ # bytes and URLs never reach a span attribute.
9
+ module Riffer::Tracing::Capture # :nodoc: all
10
+ extend self
11
+
12
+ #--
13
+ #: (Array[Riffer::Messages::Base]) -> String
14
+ def input_messages(messages)
15
+ JSON.generate(messages.filter_map { |message| convert_message(message) })
16
+ end
17
+
18
+ #--
19
+ #: (Array[Riffer::Messages::Base]) -> String?
20
+ def system_instructions(messages)
21
+ parts = messages.grep(Riffer::Messages::System).map { |message| text_part(message.content) }
22
+ return nil if parts.empty?
23
+
24
+ JSON.generate(parts)
25
+ end
26
+
27
+ #--
28
+ #: (content: String?, tool_calls: Array[Riffer::Messages::Assistant::ToolCall], finish_reason: Symbol?) -> String
29
+ def output_messages(content:, tool_calls:, finish_reason:)
30
+ message = {role: "assistant", parts: assistant_parts(content, tool_calls)} #: Hash[Symbol, untyped]
31
+ message[:finish_reason] = finish_reason if finish_reason
32
+ JSON.generate([message])
33
+ end
34
+
35
+ private
36
+
37
+ #--
38
+ #: (Riffer::Messages::Base) -> Hash[Symbol, untyped]?
39
+ def convert_message(message)
40
+ case message
41
+ when Riffer::Messages::User
42
+ parts = [text_part(message.content)] #: Array[Hash[Symbol, untyped]]
43
+ parts.concat(message.files.map { |file| file_part(file) })
44
+ {role: "user", parts: parts}
45
+ when Riffer::Messages::Assistant
46
+ {role: "assistant", parts: assistant_parts(message.content, message.tool_calls)}
47
+ when Riffer::Messages::Tool
48
+ {role: "tool", parts: [{type: "tool_call_response", id: message.tool_call_id, response: message.content}]}
49
+ end
50
+ end
51
+
52
+ #--
53
+ #: (String?, Array[Riffer::Messages::Assistant::ToolCall]) -> Array[Hash[Symbol, untyped]]
54
+ def assistant_parts(content, tool_calls)
55
+ parts = [] #: Array[Hash[Symbol, untyped]]
56
+ parts << text_part(content) if content && !content.empty?
57
+ parts.concat(tool_calls.map { |tool_call| tool_call_part(tool_call) })
58
+ parts
59
+ end
60
+
61
+ #--
62
+ #: (String?) -> Hash[Symbol, untyped]
63
+ def text_part(content)
64
+ {type: "text", content: content}
65
+ end
66
+
67
+ #--
68
+ #: (Riffer::Messages::Assistant::ToolCall) -> Hash[Symbol, untyped]
69
+ def tool_call_part(tool_call)
70
+ {type: "tool_call", id: tool_call.call_id, name: tool_call.name, arguments: parse_arguments(tool_call.arguments)}
71
+ end
72
+
73
+ #--
74
+ #: (Riffer::Messages::FilePart) -> Hash[Symbol, untyped]
75
+ def file_part(file)
76
+ part = {type: "file", media_type: file.media_type} #: Hash[Symbol, untyped]
77
+ part[:name] = file.filename if file.filename
78
+ part
79
+ end
80
+
81
+ # Semconv's tool_call part carries arguments as a JSON object; riffer holds
82
+ # them as a string — parse so the captured payload isn't double-encoded.
83
+ #--
84
+ #: (untyped) -> untyped
85
+ def parse_arguments(arguments)
86
+ return arguments unless arguments.is_a?(String)
87
+
88
+ JSON.parse(arguments)
89
+ rescue JSON::ParserError
90
+ arguments
91
+ end
92
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # No-op tracing backend, used when OTEL is unavailable or tracing is
5
+ # disabled.
6
+ module Riffer::Tracing::NoOp # :nodoc: all
7
+ extend self
8
+
9
+ # No-op stand-in for a span; answers <tt>recording?</tt> with +false+ so
10
+ # callers can skip expensive attribute serialization.
11
+ class Span
12
+ #--
13
+ #: (String, untyped) -> void
14
+ def set_attribute(key, value)
15
+ end
16
+
17
+ #--
18
+ #: (String, ?attributes: Hash[String, untyped]?) -> void
19
+ def add_event(name, attributes: nil)
20
+ end
21
+
22
+ #--
23
+ #: (Exception) -> void
24
+ def record_exception(exception)
25
+ end
26
+
27
+ #--
28
+ #: (?String) -> void
29
+ def error!(description = "")
30
+ end
31
+
32
+ #--
33
+ #: () -> bool
34
+ def recording?
35
+ false
36
+ end
37
+ end
38
+
39
+ SPAN = Span.new.freeze #: Riffer::Tracing::NoOp::Span
40
+
41
+ # Yields the no-op span, ignoring all span options.
42
+ #--
43
+ #: [R] (String, **untyped) { (Riffer::Tracing::NoOp::Span) -> R } -> R
44
+ def in_span(_name, **)
45
+ yield SPAN
46
+ end
47
+
48
+ # Returns +nil+; there is no trace context without OTEL.
49
+ #--
50
+ #: () -> nil
51
+ def current_context
52
+ nil
53
+ end
54
+
55
+ # Yields immediately; there is no context to attach.
56
+ #--
57
+ #: [R] (untyped) { () -> R } -> R
58
+ def with_context(_context)
59
+ yield
60
+ end
61
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # OTEL-backed tracing backend. <tt>::OpenTelemetry</tt> constants appear only
5
+ # inside method bodies here, so the gem loads and eager-loads cleanly when the
6
+ # OpenTelemetry API is absent.
7
+ class Riffer::Tracing::Otel # :nodoc: all
8
+ SUPPORTED_API_VERSIONS = Gem::Requirement.new(">= 1.1", "< 2") #: Gem::Requirement
9
+
10
+ # Wraps a live OTEL span behind the port's span surface, so callers never
11
+ # touch <tt>::OpenTelemetry</tt> constants (status objects in particular).
12
+ class Span
13
+ # @rbs @otel_span: untyped
14
+
15
+ #--
16
+ #: (untyped) -> void
17
+ def initialize(otel_span)
18
+ @otel_span = otel_span
19
+ end
20
+
21
+ #--
22
+ #: (String, untyped) -> void
23
+ def set_attribute(key, value)
24
+ @otel_span.set_attribute(key, value)
25
+ end
26
+
27
+ #--
28
+ #: (String, ?attributes: Hash[String, untyped]?) -> void
29
+ def add_event(name, attributes: nil)
30
+ @otel_span.add_event(name, attributes: attributes)
31
+ end
32
+
33
+ #--
34
+ #: (Exception) -> void
35
+ def record_exception(exception)
36
+ @otel_span.record_exception(exception)
37
+ end
38
+
39
+ # Marks the span status as error.
40
+ #--
41
+ #: (?String) -> void
42
+ def error!(description = "")
43
+ @otel_span.status = ::OpenTelemetry::Trace::Status.error(description)
44
+ end
45
+
46
+ #--
47
+ #: () -> bool
48
+ def recording?
49
+ @otel_span.recording?
50
+ end
51
+ end
52
+
53
+ class << self
54
+ # Builds a backend when the OpenTelemetry API is loadable at a supported
55
+ # version; returns +nil+ so resolution falls back to NoOp. +provider+
56
+ # defaults to the global <tt>OpenTelemetry.tracer_provider</tt>.
57
+ #--
58
+ #: (?provider: untyped) -> Riffer::Tracing::Otel?
59
+ def build(provider: nil)
60
+ version = api_version
61
+ return nil unless version
62
+
63
+ unless supported?(version)
64
+ Kernel.warn "riffer: opentelemetry-api #{version} is outside the supported range (#{SUPPORTED_API_VERSIONS}); tracing is disabled"
65
+ return nil
66
+ end
67
+
68
+ new(provider: provider || ::OpenTelemetry.tracer_provider)
69
+ end
70
+
71
+ # Whether the OpenTelemetry API gem is loadable at a supported version.
72
+ #--
73
+ #: () -> bool
74
+ def available?
75
+ version = api_version
76
+ !version.nil? && supported?(version)
77
+ end
78
+
79
+ # Whether the given opentelemetry-api version is one riffer codes
80
+ # against. The gem is undeclared, so this guard is the only protection
81
+ # against an incompatible API.
82
+ #--
83
+ #: (Gem::Version) -> bool
84
+ def supported?(version)
85
+ SUPPORTED_API_VERSIONS.satisfied_by?(version)
86
+ end
87
+
88
+ private
89
+
90
+ #--
91
+ #: () -> Gem::Version?
92
+ def api_version
93
+ require "opentelemetry"
94
+ spec = Gem.loaded_specs["opentelemetry-api"] #: untyped
95
+ spec&.version
96
+ rescue ::LoadError
97
+ nil
98
+ end
99
+ end
100
+
101
+ # @rbs @tracer: untyped
102
+
103
+ #--
104
+ #: (provider: untyped) -> void
105
+ def initialize(provider:)
106
+ @tracer = provider.tracer("riffer", Riffer::VERSION)
107
+ end
108
+
109
+ # Opens an OTEL span around the block, yielding the wrapped span.
110
+ #--
111
+ #: [R] (String, attributes: Hash[String, untyped]?, kind: Symbol) { (Riffer::Tracing::Otel::Span) -> R } -> R
112
+ def in_span(name, attributes:, kind:)
113
+ @tracer.in_span(name, attributes: attributes, kind: kind) do |otel_span, _context|
114
+ yield Span.new(otel_span)
115
+ end
116
+ end
117
+
118
+ # Returns the active OTEL context.
119
+ #--
120
+ #: () -> untyped
121
+ def current_context
122
+ ::OpenTelemetry::Context.current
123
+ end
124
+
125
+ # Runs the block with the given OTEL context active.
126
+ #--
127
+ #: [R] (untyped) { () -> R } -> R
128
+ def with_context(context)
129
+ return yield if context.nil?
130
+ ::OpenTelemetry::Context.with_current(context) { yield }
131
+ end
132
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ # Wraps a stream yielder to observe terminal events for span stamping while
5
+ # forwarding every event downstream untouched.
6
+ class Riffer::Tracing::StreamRecorder # :nodoc: all
7
+ # @rbs @yielder: Enumerator::Yielder
8
+
9
+ attr_reader :token_usage #: Riffer::Providers::TokenUsage?
10
+
11
+ attr_reader :finish_reason #: Symbol?
12
+
13
+ attr_reader :raw_finish_reason #: String?
14
+
15
+ attr_reader :content #: String?
16
+
17
+ attr_reader :tool_calls #: Array[Riffer::Messages::Assistant::ToolCall]
18
+
19
+ #--
20
+ #: (Enumerator::Yielder) -> void
21
+ def initialize(yielder)
22
+ @yielder = yielder
23
+ @tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
24
+ end
25
+
26
+ #--
27
+ #: (Riffer::StreamEvents::Base) -> self
28
+ def <<(event)
29
+ record(event)
30
+ @yielder << event
31
+ self
32
+ end
33
+
34
+ private
35
+
36
+ #--
37
+ #: (Riffer::StreamEvents::Base) -> void
38
+ def record(event)
39
+ case event
40
+ when Riffer::StreamEvents::TextDone
41
+ @content = event.content
42
+ when Riffer::StreamEvents::ToolCallDone
43
+ @tool_calls << Riffer::Messages::Assistant::ToolCall.new(call_id: event.call_id, name: event.name, arguments: event.arguments)
44
+ when Riffer::StreamEvents::TokenUsageDone
45
+ @token_usage = event.token_usage
46
+ when Riffer::StreamEvents::FinishReasonDone
47
+ @finish_reason = event.finish_reason
48
+ @raw_finish_reason = event.raw_finish_reason
49
+ end
50
+ end
51
+ end