ai-agents 0.10.0 → 0.12.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/CHANGELOG.md +14 -0
- data/README.md +20 -2
- data/docs/guides/rails-integration.md +13 -0
- data/docs/index.md +20 -0
- data/lib/agents/agent.rb +12 -3
- data/lib/agents/helpers/message_extractor.rb +24 -1
- data/lib/agents/instrumentation/constants.rb +4 -0
- data/lib/agents/instrumentation/tracing_callbacks.rb +105 -13
- data/lib/agents/instrumentation.rb +19 -1
- data/lib/agents/runner.rb +46 -4
- data/lib/agents/version.rb +1 -1
- data/lib/agents.rb +7 -3
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7948199bfccd3d11e42735b9ee9f34a2431d00de5876785fa26e369a128a686c
|
|
4
|
+
data.tar.gz: 7a5a91260825a6c2fc59192dee83e8e050a1260fef3d6a0589a9d1dbd0fbc0fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d314e6d427f6c9ac5d38ea39b77191c8535e5d0e400690afb15cff48588b7e969b7cb30e88d12e78c027175cdc497e92a74feb899b99f50416af006e3edabe79
|
|
7
|
+
data.tar.gz: 67b9da18847cddfae2b7866f606d335b51690aeb13c9062b8ae2afa8526ed9ae3343fbf86dd435354ee42b6d3145cefcac3e090ee5ca170895cbfc84f3b510b0
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.0] - 2026-06-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Propagate Langfuse trace metadata and filter attributes from root traces to child agent, tool, and generation spans, while keeping span-specific input/output attributes local to each span.
|
|
14
|
+
- Support optional generation-level attributes via `attribute_provider#generation_attributes(context_wrapper, chat, message)`.
|
|
15
|
+
- Add `gen_ai.request.temperature` to generation spans when an agent temperature is configured.
|
|
16
|
+
|
|
17
|
+
## [0.11.0] - 2026-05-27
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Support RubyLLM provider overrides and custom model/deployment IDs on agents, including Azure configuration passthrough (#66)
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Preserve per-message assistant agent attribution when restoring and snapshotting multi-agent conversation history, preventing earlier assistant messages from being re-labeled as the final active agent after handoffs (#68)
|
|
10
24
|
|
|
11
25
|
## [0.10.0] - 2026-04-20
|
|
12
26
|
|
data/README.md
CHANGED
|
@@ -193,22 +193,40 @@ ruby examples/isp-support/interactive.rb
|
|
|
193
193
|
Agents.configure do |config|
|
|
194
194
|
# Provider API keys
|
|
195
195
|
config.openai_api_key = ENV['OPENAI_API_KEY']
|
|
196
|
+
config.azure_api_base = ENV['AZURE_API_BASE']
|
|
197
|
+
config.azure_api_key = ENV['AZURE_API_KEY']
|
|
196
198
|
config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
|
|
197
199
|
config.gemini_api_key = ENV['GEMINI_API_KEY']
|
|
198
200
|
|
|
199
201
|
# Defaults
|
|
200
|
-
config.default_provider = :openai
|
|
201
202
|
config.default_model = 'gpt-4o'
|
|
202
203
|
|
|
203
204
|
# Performance
|
|
204
205
|
config.request_timeout = 120
|
|
205
|
-
config.max_turns = 10
|
|
206
206
|
|
|
207
207
|
# Debugging
|
|
208
208
|
config.debug = true
|
|
209
209
|
end
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
+
### Azure and Custom Deployments
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
Agents.configure do |config|
|
|
216
|
+
config.azure_api_base = ENV["AZURE_API_BASE"]
|
|
217
|
+
config.azure_api_key = ENV["AZURE_API_KEY"]
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
agent = Agents::Agent.new(
|
|
221
|
+
name: "Support",
|
|
222
|
+
model: "my-azure-deployment",
|
|
223
|
+
provider: :azure,
|
|
224
|
+
assume_model_exists: true
|
|
225
|
+
)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`provider` is optional for known, unambiguous registry models. Set it for custom deployment names and for model IDs that can exist under multiple providers, such as Azure and OpenAI deployments.
|
|
229
|
+
|
|
212
230
|
## 🔍 Observability
|
|
213
231
|
|
|
214
232
|
Optional OpenTelemetry instrumentation for tracing agent execution, compatible with
|
|
@@ -24,12 +24,25 @@ Configure your LLM providers in an initializer:
|
|
|
24
24
|
# config/initializers/ai_agents.rb
|
|
25
25
|
Agents.configure do |config|
|
|
26
26
|
config.openai_api_key = Rails.application.credentials.openai_api_key
|
|
27
|
+
config.azure_api_base = Rails.application.credentials.azure_api_base
|
|
28
|
+
config.azure_api_key = Rails.application.credentials.azure_api_key
|
|
27
29
|
config.anthropic_api_key = Rails.application.credentials.anthropic_api_key
|
|
28
30
|
config.default_model = 'gpt-4o-mini'
|
|
29
31
|
config.debug = Rails.env.development?
|
|
30
32
|
end
|
|
31
33
|
```
|
|
32
34
|
|
|
35
|
+
For Azure custom deployment names, pass the provider at the agent level:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
support = Agents::Agent.new(
|
|
39
|
+
name: "Support",
|
|
40
|
+
model: "my-azure-deployment",
|
|
41
|
+
provider: :azure,
|
|
42
|
+
assume_model_exists: true
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
33
46
|
## ActiveRecord Integration
|
|
34
47
|
|
|
35
48
|
### Conversation Persistence
|
data/docs/index.md
CHANGED
|
@@ -61,6 +61,8 @@ require 'agents'
|
|
|
61
61
|
# Configure your API keys
|
|
62
62
|
Agents.configure do |config|
|
|
63
63
|
config.openai_api_key = ENV['OPENAI_API_KEY']
|
|
64
|
+
# config.azure_api_base = ENV['AZURE_API_BASE']
|
|
65
|
+
# config.azure_api_key = ENV['AZURE_API_KEY']
|
|
64
66
|
# config.anthropic_api_key = ENV['ANTHROPIC_API_KEY']
|
|
65
67
|
# config.gemini_api_key = ENV['GEMINI_API_KEY']
|
|
66
68
|
config.default_model = 'gpt-4o-mini'
|
|
@@ -87,6 +89,24 @@ result = runner.run("I need help with a technical issue")
|
|
|
87
89
|
puts result.output
|
|
88
90
|
```
|
|
89
91
|
|
|
92
|
+
### Azure and Custom Deployments
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Agents.configure do |config|
|
|
96
|
+
config.azure_api_base = ENV["AZURE_API_BASE"]
|
|
97
|
+
config.azure_api_key = ENV["AZURE_API_KEY"]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
agent = Agents::Agent.new(
|
|
101
|
+
name: "Support",
|
|
102
|
+
model: "my-azure-deployment",
|
|
103
|
+
provider: :azure,
|
|
104
|
+
assume_model_exists: true
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`provider` is optional for known, unambiguous registry models. Set it for custom deployment names and duplicate model IDs, such as Azure and OpenAI deployments.
|
|
109
|
+
|
|
90
110
|
## Next Steps
|
|
91
111
|
|
|
92
112
|
- [Learn about Agents](concepts/agents.html)
|
data/lib/agents/agent.rb
CHANGED
|
@@ -50,24 +50,29 @@ require_relative "helpers/hash_normalizer"
|
|
|
50
50
|
# )
|
|
51
51
|
module Agents
|
|
52
52
|
class Agent
|
|
53
|
-
attr_reader :name, :instructions, :model, :
|
|
53
|
+
attr_reader :name, :instructions, :model, :provider, :assume_model_exists, :tools, :handoff_agents, :temperature,
|
|
54
|
+
:response_schema, :headers, :params
|
|
54
55
|
|
|
55
56
|
# Initialize a new Agent instance
|
|
56
57
|
#
|
|
57
58
|
# @param name [String] The name of the agent
|
|
58
59
|
# @param instructions [String, Proc, nil] Static string or dynamic Proc that returns instructions
|
|
59
60
|
# @param model [String] The LLM model to use (default: "gpt-4.1-mini")
|
|
61
|
+
# @param provider [Symbol, String, nil] Optional RubyLLM provider override
|
|
62
|
+
# @param assume_model_exists [Boolean] Whether RubyLLM should skip registry validation for custom model IDs
|
|
60
63
|
# @param tools [Array<Agents::Tool>] Array of tool instances the agent can use
|
|
61
64
|
# @param handoff_agents [Array<Agents::Agent>] Array of agents this agent can hand off to
|
|
62
65
|
# @param temperature [Float] Controls randomness in responses (0.0 = deterministic, 1.0 = very random, default: 0.7)
|
|
63
66
|
# @param response_schema [Hash, nil] JSON schema for structured output responses
|
|
64
67
|
# @param headers [Hash, nil] Default HTTP headers applied to LLM requests
|
|
65
68
|
# @param params [Hash, nil] Default provider-specific parameters applied to LLM requests (e.g., service_tier)
|
|
66
|
-
def initialize(name:, instructions: nil, model: "gpt-4.1-mini",
|
|
67
|
-
response_schema: nil, headers: nil, params: nil)
|
|
69
|
+
def initialize(name:, instructions: nil, model: "gpt-4.1-mini", provider: nil, assume_model_exists: false,
|
|
70
|
+
tools: [], handoff_agents: [], temperature: 0.7, response_schema: nil, headers: nil, params: nil)
|
|
68
71
|
@name = name
|
|
69
72
|
@instructions = instructions
|
|
70
73
|
@model = model
|
|
74
|
+
@provider = provider&.to_sym
|
|
75
|
+
@assume_model_exists = assume_model_exists
|
|
71
76
|
@tools = tools.dup
|
|
72
77
|
@handoff_agents = []
|
|
73
78
|
@temperature = temperature
|
|
@@ -155,6 +160,8 @@ module Agents
|
|
|
155
160
|
# @option changes [String] :name New agent name
|
|
156
161
|
# @option changes [String, Proc] :instructions New instructions
|
|
157
162
|
# @option changes [String] :model New model identifier
|
|
163
|
+
# @option changes [Symbol, String, nil] :provider New provider override
|
|
164
|
+
# @option changes [Boolean] :assume_model_exists Whether to skip model registry validation
|
|
158
165
|
# @option changes [Array<Agents::Tool>] :tools New tools array (replaces all tools)
|
|
159
166
|
# @option changes [Array<Agents::Agent>] :handoff_agents New handoff agents
|
|
160
167
|
# @option changes [Float] :temperature Temperature for LLM responses (0.0-1.0)
|
|
@@ -165,6 +172,8 @@ module Agents
|
|
|
165
172
|
name: changes.fetch(:name, @name),
|
|
166
173
|
instructions: changes.fetch(:instructions, @instructions),
|
|
167
174
|
model: changes.fetch(:model, @model),
|
|
175
|
+
provider: changes.fetch(:provider, @provider),
|
|
176
|
+
assume_model_exists: changes.fetch(:assume_model_exists, @assume_model_exists),
|
|
168
177
|
tools: changes.fetch(:tools, @tools.dup),
|
|
169
178
|
handoff_agents: changes.fetch(:handoff_agents, @handoff_agents),
|
|
170
179
|
temperature: changes.fetch(:temperature, @temperature),
|
|
@@ -18,8 +18,30 @@
|
|
|
18
18
|
module Agents
|
|
19
19
|
module Helpers
|
|
20
20
|
module MessageExtractor
|
|
21
|
+
# RubyLLM::Message has no metadata/extension API, so agent ownership is stored
|
|
22
|
+
# as an SDK-namespaced ivar on the message object until it is extracted.
|
|
23
|
+
#
|
|
24
|
+
# Caveat: RubyLLM::Message#instance_variables is overridden to hide :@raw but does
|
|
25
|
+
# not hide this ivar, so it will appear in instance_variables listings. This is
|
|
26
|
+
# harmless for our own code path (we read it via instance_variable_get and
|
|
27
|
+
# serialize through extract_messages, not Marshal), but external introspection
|
|
28
|
+
# of a message's ivars will see :@agents_authoring_agent.
|
|
29
|
+
AUTHORING_AGENT_IVAR = :@agents_authoring_agent
|
|
30
|
+
|
|
21
31
|
module_function
|
|
22
32
|
|
|
33
|
+
def assign_agent_name(message, agent_name)
|
|
34
|
+
return unless message && agent_name
|
|
35
|
+
|
|
36
|
+
message.instance_variable_set(AUTHORING_AGENT_IVAR, agent_name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attributed_agent_name_for(message)
|
|
40
|
+
return unless message&.instance_variable_defined?(AUTHORING_AGENT_IVAR)
|
|
41
|
+
|
|
42
|
+
message.instance_variable_get(AUTHORING_AGENT_IVAR)
|
|
43
|
+
end
|
|
44
|
+
|
|
23
45
|
# Check if content is considered empty (handles both String and Hash content)
|
|
24
46
|
#
|
|
25
47
|
# @param content [String, Hash, nil] The content to check
|
|
@@ -65,7 +87,8 @@ module Agents
|
|
|
65
87
|
|
|
66
88
|
return message unless msg.role == :assistant
|
|
67
89
|
|
|
68
|
-
|
|
90
|
+
attributed_agent_name = attributed_agent_name_for(msg) || current_agent&.name
|
|
91
|
+
message[:agent_name] = attributed_agent_name if attributed_agent_name
|
|
69
92
|
|
|
70
93
|
if tool_calls_present
|
|
71
94
|
# RubyLLM stores tool_calls as Hash with call_id => ToolCall object
|
|
@@ -15,6 +15,7 @@ module Agents
|
|
|
15
15
|
|
|
16
16
|
# GenAI semantic conventions (ONLY on generation spans)
|
|
17
17
|
ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
|
|
18
|
+
ATTR_GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"
|
|
18
19
|
ATTR_GEN_AI_PROVIDER = "gen_ai.provider.name"
|
|
19
20
|
ATTR_GEN_AI_USAGE_INPUT = "gen_ai.usage.input_tokens"
|
|
20
21
|
ATTR_GEN_AI_USAGE_OUTPUT = "gen_ai.usage.output_tokens"
|
|
@@ -22,14 +23,17 @@ module Agents
|
|
|
22
23
|
# Langfuse trace-level attributes
|
|
23
24
|
ATTR_LANGFUSE_USER_ID = "langfuse.user.id"
|
|
24
25
|
ATTR_LANGFUSE_SESSION_ID = "langfuse.session.id"
|
|
26
|
+
ATTR_LANGFUSE_PREFIX = "langfuse."
|
|
25
27
|
ATTR_LANGFUSE_TRACE_TAGS = "langfuse.trace.tags"
|
|
26
28
|
ATTR_LANGFUSE_TRACE_INPUT = "langfuse.trace.input"
|
|
27
29
|
ATTR_LANGFUSE_TRACE_OUTPUT = "langfuse.trace.output"
|
|
30
|
+
ATTR_LANGFUSE_TRACE_METADATA_PREFIX = "langfuse.trace.metadata."
|
|
28
31
|
|
|
29
32
|
# Langfuse observation-level attributes
|
|
30
33
|
ATTR_LANGFUSE_OBS_TYPE = "langfuse.observation.type"
|
|
31
34
|
ATTR_LANGFUSE_OBS_INPUT = "langfuse.observation.input"
|
|
32
35
|
ATTR_LANGFUSE_OBS_OUTPUT = "langfuse.observation.output"
|
|
36
|
+
ATTR_LANGFUSE_OBS_METADATA_PREFIX = "langfuse.observation.metadata."
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
end
|
|
@@ -18,6 +18,15 @@ module Agents
|
|
|
18
18
|
class TracingCallbacks
|
|
19
19
|
include Constants
|
|
20
20
|
|
|
21
|
+
CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES = [
|
|
22
|
+
ATTR_LANGFUSE_TRACE_INPUT,
|
|
23
|
+
ATTR_LANGFUSE_TRACE_OUTPUT,
|
|
24
|
+
ATTR_LANGFUSE_OBS_INPUT,
|
|
25
|
+
ATTR_LANGFUSE_OBS_OUTPUT,
|
|
26
|
+
ATTR_LANGFUSE_OBS_TYPE
|
|
27
|
+
].freeze
|
|
28
|
+
private_constant :CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES
|
|
29
|
+
|
|
21
30
|
def initialize(tracer:, trace_name: SPAN_RUN, span_attributes: {}, attribute_provider: nil)
|
|
22
31
|
@tracer = tracer
|
|
23
32
|
@trace_name = trace_name
|
|
@@ -31,6 +40,7 @@ module Agents
|
|
|
31
40
|
|
|
32
41
|
def on_run_start(agent_name, input, context_wrapper)
|
|
33
42
|
attributes = build_root_attributes(agent_name, input, context_wrapper)
|
|
43
|
+
child_attributes = build_child_langfuse_attributes(attributes)
|
|
34
44
|
|
|
35
45
|
root_span = @tracer.start_span(@trace_name, attributes: attributes)
|
|
36
46
|
root_context = OpenTelemetry::Trace.context_with_span(root_span)
|
|
@@ -38,6 +48,7 @@ module Agents
|
|
|
38
48
|
store_tracing_state(context_wrapper,
|
|
39
49
|
root_span: root_span,
|
|
40
50
|
root_context: root_context,
|
|
51
|
+
child_langfuse_attributes: child_attributes,
|
|
41
52
|
current_tool_span: nil,
|
|
42
53
|
current_agent_name: nil,
|
|
43
54
|
current_agent_span: nil,
|
|
@@ -66,12 +77,14 @@ module Agents
|
|
|
66
77
|
finish_agent_span(tracing)
|
|
67
78
|
end
|
|
68
79
|
|
|
69
|
-
def on_chat_created(chat, agent_name, model, context_wrapper)
|
|
80
|
+
def on_chat_created(chat, agent_name, model, context_wrapper, temperature = nil)
|
|
70
81
|
tracing = tracing_state(context_wrapper)
|
|
71
82
|
return unless tracing
|
|
72
83
|
|
|
84
|
+
request_attributes = { model: model, temperature: temperature }
|
|
85
|
+
|
|
73
86
|
chat.on_end_message do |message|
|
|
74
|
-
handle_end_message(chat, agent_name,
|
|
87
|
+
handle_end_message(chat, agent_name, request_attributes, message, context_wrapper)
|
|
75
88
|
end
|
|
76
89
|
end
|
|
77
90
|
|
|
@@ -84,6 +97,7 @@ module Agents
|
|
|
84
97
|
ATTR_LANGFUSE_OBS_TYPE => "tool",
|
|
85
98
|
ATTR_LANGFUSE_OBS_INPUT => serialize_output(args)
|
|
86
99
|
}
|
|
100
|
+
attributes.merge!(tracing[:child_langfuse_attributes])
|
|
87
101
|
|
|
88
102
|
parent = handoff_tool?(tool_name) ? tracing[:root_context] : parent_context(tracing)
|
|
89
103
|
tool_span = @tracer.start_span(
|
|
@@ -139,18 +153,19 @@ module Agents
|
|
|
139
153
|
|
|
140
154
|
private
|
|
141
155
|
|
|
142
|
-
def handle_end_message(chat, _agent_name,
|
|
156
|
+
def handle_end_message(chat, _agent_name, request_attributes, message, context_wrapper)
|
|
143
157
|
return unless message.respond_to?(:role) && message.role == :assistant
|
|
144
158
|
|
|
145
159
|
tracing = tracing_state(context_wrapper)
|
|
146
160
|
return unless tracing
|
|
147
161
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
162
|
+
llm_span = @tracer.start_span(
|
|
163
|
+
@llm_span_name,
|
|
164
|
+
with_parent: parent_context(tracing),
|
|
165
|
+
attributes: generation_span_attributes(tracing, chat, message, context_wrapper)
|
|
166
|
+
)
|
|
152
167
|
|
|
153
|
-
llm_span
|
|
168
|
+
set_llm_request_attributes(llm_span, request_attributes)
|
|
154
169
|
|
|
155
170
|
output = llm_output_text(message)
|
|
156
171
|
set_llm_response_attributes(llm_span, message, output)
|
|
@@ -159,6 +174,22 @@ module Agents
|
|
|
159
174
|
llm_span.finish
|
|
160
175
|
end
|
|
161
176
|
|
|
177
|
+
def generation_span_attributes(tracing, chat, message, context_wrapper)
|
|
178
|
+
attrs = tracing[:child_langfuse_attributes].dup
|
|
179
|
+
input = format_chat_messages(chat)
|
|
180
|
+
attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input
|
|
181
|
+
apply_generation_dynamic_attributes(attrs, context_wrapper, chat, message)
|
|
182
|
+
attrs
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def set_llm_request_attributes(span, request_attributes)
|
|
186
|
+
model = request_attributes[:model]
|
|
187
|
+
temperature = request_attributes[:temperature]
|
|
188
|
+
|
|
189
|
+
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
|
|
190
|
+
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, temperature) unless temperature.nil?
|
|
191
|
+
end
|
|
192
|
+
|
|
162
193
|
def finish_dangling_spans(tracing)
|
|
163
194
|
if tracing[:current_tool_span]
|
|
164
195
|
tracing[:current_tool_span].finish
|
|
@@ -250,13 +281,9 @@ module Agents
|
|
|
250
281
|
finish_agent_span(tracing) # close previous agent span if missed
|
|
251
282
|
|
|
252
283
|
span_name = format(@agent_span_name, agent_name)
|
|
253
|
-
attrs = { "agent.name" => agent_name }
|
|
254
|
-
input = tracing[:pending_llm_input]
|
|
255
|
-
attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
|
|
256
|
-
|
|
257
284
|
agent_span = @tracer.start_span(span_name,
|
|
258
285
|
with_parent: tracing[:root_context],
|
|
259
|
-
attributes:
|
|
286
|
+
attributes: agent_span_attributes(tracing, agent_name))
|
|
260
287
|
agent_context = OpenTelemetry::Trace.context_with_span(agent_span)
|
|
261
288
|
|
|
262
289
|
tracing[:current_agent_name] = agent_name
|
|
@@ -265,6 +292,13 @@ module Agents
|
|
|
265
292
|
tracing[:last_agent_output] = nil
|
|
266
293
|
end
|
|
267
294
|
|
|
295
|
+
def agent_span_attributes(tracing, agent_name)
|
|
296
|
+
attrs = tracing[:child_langfuse_attributes].merge("agent.name" => agent_name)
|
|
297
|
+
input = tracing[:pending_llm_input]
|
|
298
|
+
attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
|
|
299
|
+
attrs
|
|
300
|
+
end
|
|
301
|
+
|
|
268
302
|
def finish_agent_span(tracing)
|
|
269
303
|
return unless tracing[:current_agent_span]
|
|
270
304
|
|
|
@@ -297,6 +331,57 @@ module Agents
|
|
|
297
331
|
attributes
|
|
298
332
|
end
|
|
299
333
|
|
|
334
|
+
def build_child_langfuse_attributes(root_attributes)
|
|
335
|
+
root_attributes.each_with_object({}) do |(key, value), attrs|
|
|
336
|
+
next if value.nil?
|
|
337
|
+
next unless child_langfuse_attribute?(key)
|
|
338
|
+
|
|
339
|
+
attrs[key] = value
|
|
340
|
+
add_observation_metadata_mirror(attrs, key, value) if mirrored_observation_metadata_attribute?(key)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def child_langfuse_attribute?(key)
|
|
345
|
+
key.start_with?(ATTR_LANGFUSE_PREFIX) && !child_langfuse_excluded_attribute?(key)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def child_langfuse_excluded_attribute?(key)
|
|
349
|
+
CHILD_LANGFUSE_EXCLUDED_ATTRIBUTES.include?(key)
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def mirrored_observation_metadata_attribute?(key)
|
|
353
|
+
key == ATTR_LANGFUSE_USER_ID ||
|
|
354
|
+
key == ATTR_LANGFUSE_SESSION_ID ||
|
|
355
|
+
key == ATTR_LANGFUSE_TRACE_TAGS ||
|
|
356
|
+
key.start_with?(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def add_observation_metadata_mirror(attrs, key, value)
|
|
360
|
+
metadata_key = observation_metadata_mirror_key(key)
|
|
361
|
+
attrs[observation_metadata_key(metadata_key)] = serialize_metadata_value(value)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def observation_metadata_mirror_key(key)
|
|
365
|
+
case key
|
|
366
|
+
when ATTR_LANGFUSE_USER_ID
|
|
367
|
+
"user_id"
|
|
368
|
+
when ATTR_LANGFUSE_SESSION_ID
|
|
369
|
+
"session_id"
|
|
370
|
+
when ATTR_LANGFUSE_TRACE_TAGS
|
|
371
|
+
"trace_tags"
|
|
372
|
+
else
|
|
373
|
+
key.delete_prefix(ATTR_LANGFUSE_TRACE_METADATA_PREFIX)
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def observation_metadata_key(key)
|
|
378
|
+
"#{ATTR_LANGFUSE_OBS_METADATA_PREFIX}#{key}"
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def serialize_metadata_value(value)
|
|
382
|
+
value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
|
|
383
|
+
end
|
|
384
|
+
|
|
300
385
|
def apply_session_id(attributes, context_wrapper)
|
|
301
386
|
session_id = context_wrapper&.context&.dig(:session_id)&.to_s
|
|
302
387
|
attributes[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id && !session_id.empty?
|
|
@@ -317,6 +402,13 @@ module Agents
|
|
|
317
402
|
attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
|
|
318
403
|
end
|
|
319
404
|
|
|
405
|
+
def apply_generation_dynamic_attributes(attributes, context_wrapper, chat, message)
|
|
406
|
+
return unless @attribute_provider.respond_to?(:generation_attributes)
|
|
407
|
+
|
|
408
|
+
dynamic_attrs = @attribute_provider.generation_attributes(context_wrapper, chat, message)
|
|
409
|
+
attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
|
|
410
|
+
end
|
|
411
|
+
|
|
320
412
|
def store_tracing_state(context_wrapper, **state)
|
|
321
413
|
context_wrapper.context[:__otel_tracing] = state
|
|
322
414
|
end
|
|
@@ -34,6 +34,22 @@ module Agents
|
|
|
34
34
|
# { 'langfuse.user.id' => ctx.context[:account_id].to_s }
|
|
35
35
|
# }
|
|
36
36
|
# )
|
|
37
|
+
#
|
|
38
|
+
# @example With custom generation attributes
|
|
39
|
+
# class MyAttributeProvider
|
|
40
|
+
# def call(ctx)
|
|
41
|
+
# { 'langfuse.user.id' => ctx.context[:account_id].to_s }
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# def generation_attributes(_ctx, _chat, message)
|
|
45
|
+
# { 'app.generation.has_tool_calls' => message.tool_calls&.any? }
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# Agents::Instrumentation.install(runner,
|
|
50
|
+
# tracer: tracer,
|
|
51
|
+
# attribute_provider: MyAttributeProvider.new
|
|
52
|
+
# )
|
|
37
53
|
module Instrumentation
|
|
38
54
|
INSTALL_MUTEX = Mutex.new
|
|
39
55
|
private_constant :INSTALL_MUTEX
|
|
@@ -52,7 +68,9 @@ module Agents
|
|
|
52
68
|
# @param tracer [OpenTelemetry::Trace::Tracer] OTel tracer instance
|
|
53
69
|
# @param trace_name [String] Name for the root span (default: "agents.run")
|
|
54
70
|
# @param span_attributes [Hash] Static attributes applied to the root span
|
|
55
|
-
# @param attribute_provider [
|
|
71
|
+
# @param attribute_provider [#call, nil] Object receiving context_wrapper and returning dynamic root attributes.
|
|
72
|
+
# If it also responds to #generation_attributes, that method receives context_wrapper, chat, and message,
|
|
73
|
+
# and can return dynamic attributes for generation spans.
|
|
56
74
|
# @return [Agents::AgentRunner, nil] The runner (for chaining), or nil if OTel is unavailable
|
|
57
75
|
def self.install(runner, tracer:, trace_name: Constants::SPAN_RUN, span_attributes: {},
|
|
58
76
|
attribute_provider: nil)
|
data/lib/agents/runner.rb
CHANGED
|
@@ -103,7 +103,11 @@ module Agents
|
|
|
103
103
|
agent_params = Helpers::HashNormalizer.normalize(current_agent.params, label: "params")
|
|
104
104
|
|
|
105
105
|
# Create chat and restore conversation history
|
|
106
|
-
chat = RubyLLM::Chat.new(
|
|
106
|
+
chat = RubyLLM::Chat.new(
|
|
107
|
+
model: current_agent.model,
|
|
108
|
+
provider: current_agent.provider,
|
|
109
|
+
assume_model_exists: current_agent.assume_model_exists
|
|
110
|
+
)
|
|
107
111
|
current_headers = Helpers::HashNormalizer.merge(agent_headers, runtime_headers)
|
|
108
112
|
current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
|
|
109
113
|
apply_headers(chat, current_headers)
|
|
@@ -111,13 +115,16 @@ module Agents
|
|
|
111
115
|
configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
|
|
112
116
|
restore_conversation_history(chat, context_wrapper)
|
|
113
117
|
input_already_in_history = last_message_matches?(chat, input)
|
|
114
|
-
context_wrapper.callback_manager.emit_chat_created(
|
|
118
|
+
context_wrapper.callback_manager.emit_chat_created(
|
|
119
|
+
chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
|
|
120
|
+
)
|
|
115
121
|
|
|
116
122
|
loop do
|
|
117
123
|
current_turn += 1
|
|
118
124
|
raise MaxTurnsExceeded, "Exceeded maximum turns: #{max_turns}" if current_turn > max_turns
|
|
119
125
|
|
|
120
126
|
# Get response from LLM (RubyLLM handles tool execution with halting based handoff detection)
|
|
127
|
+
message_count_before_response = chat_message_count(chat)
|
|
121
128
|
response = if current_turn == 1
|
|
122
129
|
# Emit agent thinking event for initial message
|
|
123
130
|
context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
|
|
@@ -130,6 +137,7 @@ module Agents
|
|
|
130
137
|
context_wrapper)
|
|
131
138
|
chat.complete
|
|
132
139
|
end
|
|
140
|
+
assign_agent_name_to_new_assistant_messages(chat, current_agent, message_count_before_response)
|
|
133
141
|
track_usage(response, context_wrapper)
|
|
134
142
|
|
|
135
143
|
# Emit LLM call complete event with model and response for instrumentation
|
|
@@ -172,7 +180,7 @@ module Agents
|
|
|
172
180
|
current_params = Helpers::HashNormalizer.merge(agent_params, runtime_params)
|
|
173
181
|
apply_params(chat, current_params)
|
|
174
182
|
context_wrapper.callback_manager.emit_chat_created(
|
|
175
|
-
chat, current_agent.name, current_agent.model, context_wrapper
|
|
183
|
+
chat, current_agent.name, current_agent.model, context_wrapper, current_agent.temperature
|
|
176
184
|
)
|
|
177
185
|
|
|
178
186
|
# Force the new agent to respond to the conversation context
|
|
@@ -266,6 +274,7 @@ module Agents
|
|
|
266
274
|
next unless message_params # Skip invalid messages
|
|
267
275
|
|
|
268
276
|
message = RubyLLM::Message.new(**message_params)
|
|
277
|
+
assign_restored_agent_name(message, msg)
|
|
269
278
|
chat.add_message(message)
|
|
270
279
|
|
|
271
280
|
if message.role == :assistant && message_params[:tool_calls]
|
|
@@ -377,6 +386,33 @@ module Agents
|
|
|
377
386
|
context_wrapper.context.delete(:pending_handoff)
|
|
378
387
|
end
|
|
379
388
|
|
|
389
|
+
def assign_agent_name_to_new_assistant_messages(chat, current_agent, start_index)
|
|
390
|
+
# Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
|
|
391
|
+
# no-op guard for chat-like doubles/adapters that do not expose history.
|
|
392
|
+
return unless chat.respond_to?(:messages)
|
|
393
|
+
|
|
394
|
+
chat.messages[start_index..]&.each do |message|
|
|
395
|
+
next unless message.role == :assistant
|
|
396
|
+
|
|
397
|
+
Helpers::MessageExtractor.assign_agent_name(message, current_agent.name)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def chat_message_count(chat)
|
|
402
|
+
# Runtime chats are RubyLLM::Chat instances and expose messages. Keep this
|
|
403
|
+
# fallback for chat-like doubles/adapters where attribution is irrelevant.
|
|
404
|
+
return 0 unless chat.respond_to?(:messages)
|
|
405
|
+
|
|
406
|
+
chat.messages.length
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def assign_restored_agent_name(message, msg)
|
|
410
|
+
return unless message.role == :assistant
|
|
411
|
+
|
|
412
|
+
restored_agent_name = msg[:agent_name] || msg["agent_name"]
|
|
413
|
+
Helpers::MessageExtractor.assign_agent_name(message, restored_agent_name)
|
|
414
|
+
end
|
|
415
|
+
|
|
380
416
|
# Configures a RubyLLM chat instance with agent-specific settings.
|
|
381
417
|
# Uses RubyLLM's replace option to swap agent context while preserving conversation history during handoffs.
|
|
382
418
|
#
|
|
@@ -393,7 +429,13 @@ module Agents
|
|
|
393
429
|
all_tools = build_agent_tools(agent, context_wrapper)
|
|
394
430
|
|
|
395
431
|
# Switch model if different (important for handoffs between agents using different models)
|
|
396
|
-
|
|
432
|
+
if replace
|
|
433
|
+
chat.with_model(
|
|
434
|
+
agent.model,
|
|
435
|
+
provider: agent.provider,
|
|
436
|
+
assume_exists: agent.assume_model_exists
|
|
437
|
+
)
|
|
438
|
+
end
|
|
397
439
|
|
|
398
440
|
# Configure chat with instructions, temperature, tools, and schema
|
|
399
441
|
chat.with_instructions(system_prompt, replace: replace) if system_prompt
|
data/lib/agents/version.rb
CHANGED
data/lib/agents.rb
CHANGED
|
@@ -56,6 +56,9 @@ module Agents
|
|
|
56
56
|
|
|
57
57
|
# Other providers
|
|
58
58
|
apply_if_present(config, :anthropic_api_key)
|
|
59
|
+
apply_if_present(config, :azure_api_base)
|
|
60
|
+
apply_if_present(config, :azure_api_key)
|
|
61
|
+
apply_if_present(config, :azure_ai_auth_token)
|
|
59
62
|
apply_if_present(config, :gemini_api_key)
|
|
60
63
|
apply_if_present(config, :deepseek_api_key)
|
|
61
64
|
apply_if_present(config, :openrouter_api_key)
|
|
@@ -83,8 +86,9 @@ module Agents
|
|
|
83
86
|
class Configuration
|
|
84
87
|
# Provider API keys and configuration
|
|
85
88
|
attr_accessor :openai_api_key, :openai_api_base, :openai_organization_id, :openai_project_id
|
|
86
|
-
attr_accessor :anthropic_api_key, :
|
|
87
|
-
:
|
|
89
|
+
attr_accessor :anthropic_api_key, :azure_api_base, :azure_api_key, :azure_ai_auth_token, :gemini_api_key,
|
|
90
|
+
:deepseek_api_key, :openrouter_api_key, :ollama_api_base, :bedrock_api_key, :bedrock_secret_key,
|
|
91
|
+
:bedrock_region, :bedrock_session_token
|
|
88
92
|
|
|
89
93
|
# General configuration
|
|
90
94
|
attr_accessor :request_timeout, :default_model, :debug
|
|
@@ -100,7 +104,7 @@ module Agents
|
|
|
100
104
|
def configured?
|
|
101
105
|
@openai_api_key || @anthropic_api_key || @gemini_api_key ||
|
|
102
106
|
@deepseek_api_key || @openrouter_api_key || @ollama_api_base ||
|
|
103
|
-
@bedrock_api_key
|
|
107
|
+
@bedrock_api_key || (@azure_api_base && (@azure_api_key || @azure_ai_auth_token))
|
|
104
108
|
end
|
|
105
109
|
end
|
|
106
110
|
end
|