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.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +34 -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 +66 -4
- data/docs/14_MCP.md +2 -1
- data/docs/16_TRACING.md +250 -0
- data/docs/17_METRICS.md +123 -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 +5 -5
- data/lib/riffer/config.rb +231 -15
- data/lib/riffer/guardrail.rb +8 -0
- data/lib/riffer/guardrails/runner.rb +33 -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 +25 -0
- data/lib/riffer/metrics/null.rb +14 -0
- data/lib/riffer/metrics/otel.rb +79 -0
- data/lib/riffer/metrics.rb +93 -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 +12 -2
- data/lib/riffer/skills/adapter.rb +15 -0
- data/lib/riffer/skills/context.rb +78 -11
- data/lib/riffer/skills/frontmatter.rb +13 -5
- 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/null.rb +61 -0
- data/lib/riffer/tracing/otel.rb +131 -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 +14 -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 +13 -0
- data/sig/generated/riffer/metrics/null.rbs +10 -0
- data/sig/generated/riffer/metrics/otel.rbs +47 -0
- data/sig/generated/riffer/metrics.rbs +71 -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 +52 -4
- data/sig/generated/riffer/skills/frontmatter.rbs +10 -3
- 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/null.rbs +46 -0
- data/sig/generated/riffer/tracing/otel.rbs +83 -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/null.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/null.rbs +5 -0
- data/sig/manual/riffer/tracing.rbs +5 -0
- 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
|
|
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,22 @@ class Riffer::Skills::Context
|
|
|
45
97
|
#--
|
|
46
98
|
#: (String) -> bool
|
|
47
99
|
def activated?(name)
|
|
48
|
-
@activated.
|
|
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
|
-
@
|
|
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| @
|
|
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+;
|
|
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 <<
|
|
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::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
|