riffer 0.32.1 → 0.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +39 -0
- data/README.md +13 -11
- data/docs/01_OVERVIEW.md +2 -0
- data/docs/04_AGENT_LIFECYCLE.md +15 -13
- data/docs/08_MESSAGES.md +39 -5
- data/docs/09_STREAM_EVENTS.md +14 -0
- data/docs/10_CONFIGURATION.md +73 -4
- data/docs/13_SKILLS.md +62 -4
- data/docs/14_MCP.md +2 -1
- data/docs/16_TRACING.md +274 -0
- data/docs/17_METRICS.md +153 -0
- data/docs/providers/07_CUSTOM_PROVIDERS.md +44 -0
- data/lib/riffer/agent/response.rb +11 -2
- data/lib/riffer/agent/run.rb +136 -35
- data/lib/riffer/agent.rb +1 -1
- data/lib/riffer/config.rb +230 -15
- data/lib/riffer/guardrail.rb +8 -0
- data/lib/riffer/guardrails/runner.rb +58 -0
- data/lib/riffer/helpers/boolean.rb +22 -0
- data/lib/riffer/mcp/authenticated_tool.rb +14 -20
- data/lib/riffer/mcp/registration.rb +4 -4
- data/lib/riffer/mcp/tool.rb +23 -0
- data/lib/riffer/mcp/tool_factory.rb +14 -22
- data/lib/riffer/messages/assistant.rb +15 -3
- data/lib/riffer/messages/base.rb +2 -1
- data/lib/riffer/metrics/instruments.rb +31 -0
- data/lib/riffer/metrics/no_op.rb +14 -0
- data/lib/riffer/metrics/otel.rb +80 -0
- data/lib/riffer/metrics.rb +94 -0
- data/lib/riffer/providers/amazon_bedrock.rb +57 -21
- data/lib/riffer/providers/anthropic.rb +59 -24
- data/lib/riffer/providers/azure_open_ai.rb +7 -0
- data/lib/riffer/providers/base.rb +247 -15
- data/lib/riffer/providers/finish_reason.rb +27 -0
- data/lib/riffer/providers/gemini.rb +59 -11
- data/lib/riffer/providers/mock.rb +30 -9
- data/lib/riffer/providers/open_ai.rb +78 -24
- data/lib/riffer/providers/open_router.rb +56 -16
- data/lib/riffer/providers/repository.rb +9 -0
- data/lib/riffer/providers/token_usage.rb +27 -11
- data/lib/riffer/skills/activate_tool.rb +11 -2
- data/lib/riffer/skills/adapter.rb +15 -0
- data/lib/riffer/skills/context.rb +63 -11
- data/lib/riffer/skills/markdown_adapter.rb +1 -1
- data/lib/riffer/skills/xml_adapter.rb +1 -1
- data/lib/riffer/stream_events/finish_reason_done.rb +34 -0
- data/lib/riffer/tools/runtime.rb +99 -3
- data/lib/riffer/tracing/capture.rb +92 -0
- data/lib/riffer/tracing/no_op.rb +61 -0
- data/lib/riffer/tracing/otel.rb +132 -0
- data/lib/riffer/tracing/stream_recorder.rb +51 -0
- data/lib/riffer/tracing.rb +78 -0
- data/lib/riffer/version.rb +1 -1
- data/sig/_private/opentelemetry.rbs +22 -0
- data/sig/generated/riffer/agent/response.rbs +9 -2
- data/sig/generated/riffer/agent/run.rbs +28 -8
- data/sig/generated/riffer/config.rbs +162 -16
- data/sig/generated/riffer/guardrail.rbs +6 -0
- data/sig/generated/riffer/guardrails/runner.rbs +22 -0
- data/sig/generated/riffer/helpers/boolean.rbs +11 -0
- data/sig/generated/riffer/mcp/authenticated_tool.rbs +6 -8
- data/sig/generated/riffer/mcp/registration.rbs +4 -4
- data/sig/generated/riffer/mcp/tool.rbs +19 -0
- data/sig/generated/riffer/mcp/tool_factory.rbs +8 -7
- data/sig/generated/riffer/messages/assistant.rbs +10 -4
- data/sig/generated/riffer/metrics/instruments.rbs +15 -0
- data/sig/generated/riffer/metrics/no_op.rbs +10 -0
- data/sig/generated/riffer/metrics/otel.rbs +48 -0
- data/sig/generated/riffer/metrics.rbs +72 -0
- data/sig/generated/riffer/providers/amazon_bedrock.rbs +35 -14
- data/sig/generated/riffer/providers/anthropic.rbs +41 -20
- data/sig/generated/riffer/providers/azure_open_ai.rbs +5 -0
- data/sig/generated/riffer/providers/base.rbs +78 -2
- data/sig/generated/riffer/providers/finish_reason.rbs +19 -0
- data/sig/generated/riffer/providers/gemini.rbs +25 -2
- data/sig/generated/riffer/providers/mock.rbs +16 -5
- data/sig/generated/riffer/providers/open_ai.rbs +44 -22
- data/sig/generated/riffer/providers/open_router.rbs +31 -12
- data/sig/generated/riffer/providers/repository.rbs +7 -0
- data/sig/generated/riffer/providers/token_usage.rbs +20 -10
- data/sig/generated/riffer/skills/activate_tool.rbs +7 -1
- data/sig/generated/riffer/skills/adapter.rbs +10 -0
- data/sig/generated/riffer/skills/context.rbs +42 -4
- data/sig/generated/riffer/stream_events/finish_reason_done.rbs +21 -0
- data/sig/generated/riffer/tools/runtime.rbs +35 -0
- data/sig/generated/riffer/tracing/capture.rbs +46 -0
- data/sig/generated/riffer/tracing/no_op.rbs +46 -0
- data/sig/generated/riffer/tracing/otel.rbs +84 -0
- data/sig/generated/riffer/tracing/stream_recorder.rbs +31 -0
- data/sig/generated/riffer/tracing.rbs +52 -0
- data/sig/manual/riffer/helpers/boolean.rbs +5 -0
- data/sig/manual/riffer/metrics/no_op.rbs +5 -0
- data/sig/manual/riffer/metrics.rbs +5 -0
- data/sig/manual/riffer/providers.rbs +9 -0
- data/sig/manual/riffer/tracing/capture.rbs +5 -0
- data/sig/manual/riffer/tracing/no_op.rbs +5 -0
- data/sig/manual/riffer/tracing.rbs +5 -0
- metadata +42 -6
|
@@ -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
|
|
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 @
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
38
|
-
|
|
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.
|
|
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
|
-
@
|
|
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| @
|
|
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 <<
|
|
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 <<
|
|
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
|
data/lib/riffer/tools/runtime.rb
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|