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
@@ -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,22 @@ class Riffer::Skills::Context
45
97
  #--
46
98
  #: (String) -> bool
47
99
  def activated?(name)
48
- @activated.key?(name)
100
+ @activated.include?(name)
101
+ end
102
+
103
+ # Returns whether a skill exists and may be activated by the model.
104
+ #--
105
+ #: (String) -> bool
106
+ def model_invocable?(name)
107
+ skill = skills[name]
108
+ !skill.nil? && !skill.disable_model_invocation
109
+ end
110
+
111
+ # Returns whether any skill is available for the model to activate.
112
+ #--
113
+ #: () -> bool
114
+ def activatable?
115
+ available_skills.any?
49
116
  end
50
117
 
51
118
  # Returns the complete skills section for the system prompt — the catalog plus
@@ -56,7 +123,7 @@ class Riffer::Skills::Context
56
123
  available = available_skills
57
124
  parts = [] #: Array[String]
58
125
  parts << @adapter.render_catalog(available) unless available.empty?
59
- @activated.each_value { |body| parts << body }
126
+ @preactivated.each { |name| parts << @adapter.render_activation(skills.fetch(name), @bodies.fetch(name)) }
60
127
  parts.join("\n\n")
61
128
  end
62
129
 
@@ -65,6 +132,6 @@ class Riffer::Skills::Context
65
132
  #--
66
133
  #: () -> Array[Riffer::Skills::Frontmatter]
67
134
  def available_skills
68
- skills.values.reject { |skill| @activated.key?(skill.name) }
135
+ skills.values.reject { |skill| @preactivated.include?(skill.name) || skill.disable_model_invocation }
69
136
  end
70
137
  end
@@ -4,7 +4,8 @@
4
4
  require "yaml"
5
5
 
6
6
  # Immutable value object holding parsed SKILL.md YAML frontmatter. Required
7
- # fields: +name+ and +description+; unrecognized top-level keys are merged into
7
+ # fields: +name+ and +description+; the optional +disable-model-invocation+
8
+ # flag is recognized, and any other unrecognized top-level keys are merged into
8
9
  # +metadata+.
9
10
  class Riffer::Skills::Frontmatter
10
11
  NAME_PATTERN = /\A[a-z0-9]+(-[a-z0-9]+)*\z/ #: Regexp
@@ -17,6 +18,11 @@ class Riffer::Skills::Frontmatter
17
18
  # The skill description (1-1024 chars).
18
19
  attr_reader :description #: String
19
20
 
21
+ # Whether the skill opts out of model-driven activation. Hidden from the
22
+ # catalog and rejected at model activation; still reachable via programmatic
23
+ # activation.
24
+ attr_reader :disable_model_invocation #: bool
25
+
20
26
  # Metadata from the spec's +metadata+ field plus any unrecognized top-level
21
27
  # keys.
22
28
  attr_reader :metadata #: Hash[Symbol, untyped]
@@ -29,7 +35,7 @@ class Riffer::Skills::Frontmatter
29
35
  def self.parse(raw)
30
36
  yaml, body = split_frontmatter(raw)
31
37
  raise Riffer::ArgumentError, "missing YAML frontmatter (expected --- delimiters)" if yaml.empty?
32
- [new(name: yaml.delete(:name), description: yaml.delete(:description), metadata: yaml), body]
38
+ [new(name: yaml.delete(:name), description: yaml.delete(:description), disable_model_invocation: yaml.delete(:"disable-model-invocation"), metadata: yaml), body]
33
39
  end
34
40
 
35
41
  # Parses only the frontmatter from a raw SKILL.md string, ignoring the body.
@@ -39,7 +45,7 @@ class Riffer::Skills::Frontmatter
39
45
  def self.parse_frontmatter(raw)
40
46
  yaml, _ = split_frontmatter(raw)
41
47
  raise Riffer::ArgumentError, "missing YAML frontmatter (expected --- delimiters)" if yaml.empty?
42
- new(name: yaml.delete(:name), description: yaml.delete(:description), metadata: yaml)
48
+ new(name: yaml.delete(:name), description: yaml.delete(:description), disable_model_invocation: yaml.delete(:"disable-model-invocation"), metadata: yaml)
43
49
  end
44
50
 
45
51
  #--
@@ -58,13 +64,15 @@ class Riffer::Skills::Frontmatter
58
64
  private_class_method :split_frontmatter
59
65
 
60
66
  # Raises Riffer::ArgumentError if +name+ or +description+ is invalid.
67
+ # +disable_model_invocation+ is treated as set only when literally +true+.
61
68
  #--
62
- #: (name: String, description: String, ?metadata: Hash[Symbol, untyped]) -> void
63
- def initialize(name:, description:, metadata: {})
69
+ #: (name: String, description: String, ?disable_model_invocation: bool, ?metadata: Hash[Symbol, untyped]) -> void
70
+ def initialize(name:, description:, disable_model_invocation: false, metadata: {})
64
71
  validate_name!(name)
65
72
  validate_description!(description)
66
73
  @name = name.freeze
67
74
  @description = description.freeze
75
+ @disable_model_invocation = (disable_model_invocation == true)
68
76
  @metadata = metadata.freeze
69
77
  end
70
78
 
@@ -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::Null::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::Null::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::Null::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::Null::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::Null::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::Null # :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::Null::Span
40
+
41
+ # Yields the no-op span, ignoring all span options.
42
+ #--
43
+ #: [R] (String, **untyped) { (Riffer::Tracing::Null::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,131 @@
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 Null.
56
+ #--
57
+ #: (provider: untyped) -> Riffer::Tracing::Otel?
58
+ def build(provider:)
59
+ version = api_version
60
+ return nil unless version
61
+
62
+ unless supported?(version)
63
+ Kernel.warn "riffer: opentelemetry-api #{version} is outside the supported range (#{SUPPORTED_API_VERSIONS}); tracing is disabled"
64
+ return nil
65
+ end
66
+
67
+ new(provider: provider || ::OpenTelemetry.tracer_provider)
68
+ end
69
+
70
+ # Whether the OpenTelemetry API gem is loadable at a supported version.
71
+ #--
72
+ #: () -> bool
73
+ def available?
74
+ version = api_version
75
+ !version.nil? && supported?(version)
76
+ end
77
+
78
+ # Whether the given opentelemetry-api version is one riffer codes
79
+ # against. The gem is undeclared, so this guard is the only protection
80
+ # against an incompatible API.
81
+ #--
82
+ #: (Gem::Version) -> bool
83
+ def supported?(version)
84
+ SUPPORTED_API_VERSIONS.satisfied_by?(version)
85
+ end
86
+
87
+ private
88
+
89
+ #--
90
+ #: () -> Gem::Version?
91
+ def api_version
92
+ require "opentelemetry"
93
+ spec = Gem.loaded_specs["opentelemetry-api"] #: untyped
94
+ spec&.version
95
+ rescue ::LoadError
96
+ nil
97
+ end
98
+ end
99
+
100
+ # @rbs @tracer: untyped
101
+
102
+ #--
103
+ #: (provider: untyped) -> void
104
+ def initialize(provider:)
105
+ @tracer = provider.tracer("riffer", Riffer::VERSION)
106
+ end
107
+
108
+ # Opens an OTEL span around the block, yielding the wrapped span.
109
+ #--
110
+ #: [R] (String, attributes: Hash[String, untyped]?, kind: Symbol) { (Riffer::Tracing::Otel::Span) -> R } -> R
111
+ def in_span(name, attributes:, kind:)
112
+ @tracer.in_span(name, attributes: attributes, kind: kind) do |otel_span, _context|
113
+ yield Span.new(otel_span)
114
+ end
115
+ end
116
+
117
+ # Returns the active OTEL context.
118
+ #--
119
+ #: () -> untyped
120
+ def current_context
121
+ ::OpenTelemetry::Context.current
122
+ end
123
+
124
+ # Runs the block with the given OTEL context active.
125
+ #--
126
+ #: [R] (untyped) { () -> R } -> R
127
+ def with_context(context)
128
+ return yield if context.nil?
129
+ ::OpenTelemetry::Context.with_current(context) { yield }
130
+ end
131
+ end