ai-agents 0.8.0 → 0.9.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6391e30443ff9e226e6b3bf3f629f3cd996cbb7fe4479c7a7dc437c82544ae2f
4
- data.tar.gz: 22fc6ee4f3130006c1dd1f6c8fb704a176457d35602a434088d25cea5dd3c949
3
+ metadata.gz: df386be7e27f87111901954d72e4caa3e26a1d789ec113fbf9e2da9d2f87587e
4
+ data.tar.gz: f05b6827852966d0514abae61c7732a34ea92aeebe30855132ec8bee39c1e4c2
5
5
  SHA512:
6
- metadata.gz: ae3866cfbec885088c5b41b0e91bbc8532fc3115ed981c3428f56c09b40c1a75bc9bb1277ed1991f19cee4c43118c3f9a5d9ea8d6ecc07d29ad03117a77666e6
7
- data.tar.gz: b7de2e98dc3ce52b4c80b7b1bcfa4fb1f07f62176d861ab61b9a1092d2de4273657d54edfa18d6afd83acccee21c89e465f18539b8966c5f9645a673b4e5a3c4
6
+ metadata.gz: 2180b6b495519d34ff4762cd027d6079fb39ad91117242f6f366779fd7360c7bbb55521761e151ad246eee3004363de9bf768ca953a8e69a1e39e2f2dfeb5345
7
+ data.tar.gz: 41dadb09fd62a2ce47b063c6b85685f1efa8be73dbee467e83ad69cdb32793926aaadacbce7bfa820960c0d5f35e57325a7078733c19bef0e1121076bc6a9002
data/.env.example ADDED
@@ -0,0 +1,6 @@
1
+ export OPENAI_API_KEY=sk-xxx
2
+ export OPENAI_MODEL=gpt-4.1-nano
3
+ export RUN_LIVE_LLM=true
4
+ export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
5
+ export LANGFUSE_SECRET_KEY=sk-lf-xxx
6
+ export LANGFUSE_HOST=https://cloud.langfuse.com
data/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.0] - 2026-02-09
11
+
12
+ ### Added
13
+ - **OpenTelemetry Instrumentation**: Optional OTel tracing for LLM calls, tool executions, and agent handoffs
14
+ - `Agents::Instrumentation.install(runner, tracer:)` registers tracing callbacks on any runner
15
+ - Produces nested spans: `agents.run` → `agents.tool.*` → `agents.llm_call` (GENERATION)
16
+ - Compatible with Langfuse and other OTel-compatible backends out of the box
17
+ - Supports `langfuse.session.id` via `context[:session_id]` for session grouping
18
+ - Custom static and dynamic span attributes via `span_attributes` and `attribute_provider`
19
+ - Idempotent installation with thread-safe mutex guard
20
+ - No hard dependency on `opentelemetry-api` — gracefully no-ops if the gem is absent
21
+ - **New callback events**: `on_chat_created` and `on_end_message` for hooking into RubyLLM chat lifecycle
22
+ - Instrumentation guide at `docs/guides/instrumentation.md`
23
+ - Live instrumentation smoke tests against real LLM providers
24
+
25
+ ### Changed
26
+ - `CallbackManager` extended to support `chat_created` and `end_message` event types
27
+ - `Runner` and `ToolWrapper` now fire the new lifecycle callbacks
28
+
10
29
  ## [0.8.0] - 2026-01-07
11
30
 
12
31
  ### Added
data/CLAUDE.md CHANGED
@@ -237,3 +237,29 @@ The SDK includes a comprehensive callback system for monitoring agent execution
237
237
  Callbacks are thread-safe and non-blocking. If a callback raises an exception, it won't interrupt agent execution. The system uses a centralized CallbackManager for efficient event handling.
238
238
 
239
239
  For detailed callback documentation, see `docs/concepts/callbacks.md`.
240
+
241
+ ## OpenTelemetry Instrumentation
242
+
243
+ The SDK includes optional OpenTelemetry instrumentation (`lib/agents/instrumentation/`) that produces spans compatible with Langfuse and other OTel backends via `Agents::Instrumentation.install(runner, tracer:, ...)`.
244
+
245
+ ### Rules for Instrumentation Code
246
+
247
+ 1. **Double-counting prevention**: NEVER set `gen_ai.request.model` on container spans (`agents.run`, `agents.tool.*`). Only `agents.llm_call` GENERATION spans get this attribute. Langfuse sums costs from every span with this attribute — setting it on both parent and child causes double counting.
248
+
249
+ 2. **Langfuse needs BOTH trace-level and observation-level I/O**: Always set both `langfuse.trace.input`/`langfuse.trace.output` AND `langfuse.observation.input`/`langfuse.observation.output` on the root span. Trace-level shows at the top of the page; observation-level shows when you click the span in the sidebar. Missing one causes null/undefined display.
250
+
251
+ 3. **Never set attributes to empty strings**: Langfuse renders empty string attributes as "undefined". If a value is empty/nil, do NOT set the attribute at all. Use guards like `unless output.empty?`.
252
+
253
+ 4. **Hash/Array content must use `.to_json`, not `.to_s`**: When `response.content` is a Hash (from `response_schema` structured output), Ruby's `.to_s` produces `{"key" => "value"}` which is unreadable. Always check `content.is_a?(Hash) || content.is_a?(Array)` and use `.to_json`.
254
+
255
+ 5. **LLM span input = `chat.messages[0...-1]` as JSON**: Use `format_chat_messages(chat)` which returns the full chat history (excluding the current response) as a JSON array of `{role, content}` messages. This naturally includes tool results since they are part of `chat.messages`. Do NOT concatenate tool results into a flat string — keep the structured role separation.
256
+
257
+ 6. **Don't use `.delete` for shared tracing state**: If a value in `context[:__otel_tracing]` needs to be read by multiple callbacks, use `tracing[:key]` not `tracing.delete(:key)`. Delete is a destructive side-effect that breaks subsequent reads.
258
+
259
+ 7. **Per-call LLM spans via `on_end_message`**: Individual GENERATION spans are created by hooking into RubyLLM's `chat.on_end_message` (registered in `on_chat_created`). Each span is created and immediately finished. There is no `current_llm_span` in tracing state — only `current_tool_span` needs single-slot tracking.
260
+
261
+ 8. **Conversation history deduplication**: `Runner#last_message_matches?` checks if the last restored message already matches the current input. If so, uses `chat.complete` instead of `chat.ask(input)` to avoid sending the user message twice.
262
+
263
+ ### Reference: Chatwoot Instrumentation
264
+
265
+ The Chatwoot codebase at `~/work/chatwoot` has a working reference implementation in `lib/integrations/llm_instrumentation_spans.rb` and `lib/integrations/llm_instrumentation.rb`. Key patterns: `messages.to_json` for observation input, `message.content.to_s` for output, `chat.messages[0...-1]` for history.
data/README.md CHANGED
@@ -209,6 +209,22 @@ Agents.configure do |config|
209
209
  end
210
210
  ```
211
211
 
212
+ ## 🔍 Observability
213
+
214
+ Optional OpenTelemetry instrumentation for tracing agent execution, compatible with
215
+ [Langfuse](https://langfuse.com) and other OTel backends.
216
+
217
+ ```ruby
218
+ require 'agents/instrumentation'
219
+
220
+ tracer = OpenTelemetry.tracer_provider.tracer('my-app')
221
+ runner = Agents::Runner.with_agents(triage, billing, support)
222
+
223
+ Agents::Instrumentation.install(runner, tracer: tracer)
224
+ ```
225
+
226
+ See the [Instrumentation Guide](docs/guides/instrumentation.md) for setup details.
227
+
212
228
  ## 🤝 Contributing
213
229
 
214
230
  1. Fork the repository
@@ -0,0 +1,268 @@
1
+ ---
2
+ layout: default
3
+ title: OpenTelemetry Instrumentation
4
+ parent: Guides
5
+ nav_order: 7
6
+ ---
7
+
8
+ # OpenTelemetry Instrumentation
9
+
10
+ Trace agent execution, LLM calls, tool usage, and handoffs using OpenTelemetry. Compatible with [Langfuse](https://langfuse.com) and any OTel-compatible backend.
11
+
12
+ ## Overview
13
+
14
+ The `Agents::Instrumentation` module produces OTel spans that give you full visibility into agent execution:
15
+
16
+ - **LLM generation spans** with model name, token counts, and input/output
17
+ - **Tool execution spans** with arguments and results
18
+ - **Agent container spans** grouping related LLM and tool calls
19
+ - **Handoff events** recording agent-to-agent transfers
20
+
21
+ Spans follow the [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/) and include Langfuse-specific attributes for rich rendering in the Langfuse dashboard.
22
+
23
+ ## Setup
24
+
25
+ ### 1. Install dependencies
26
+
27
+ Add to your Gemfile:
28
+
29
+ ```ruby
30
+ gem "opentelemetry-sdk"
31
+ gem "opentelemetry-exporter-otlp"
32
+ ```
33
+
34
+ Then run `bundle install`.
35
+
36
+ ### 2. Configure the OTel SDK
37
+
38
+ ```ruby
39
+ require "opentelemetry-sdk"
40
+ require "opentelemetry-exporter-otlp"
41
+
42
+ OpenTelemetry::SDK.configure do |c|
43
+ c.add_span_processor(
44
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
45
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
46
+ endpoint: "https://your-otel-endpoint/v1/traces",
47
+ headers: { "Authorization" => "Bearer YOUR_TOKEN" }
48
+ )
49
+ )
50
+ )
51
+ end
52
+ ```
53
+
54
+ ### 3. Install on a runner
55
+
56
+ ```ruby
57
+ require "agents/instrumentation"
58
+
59
+ tracer = OpenTelemetry.tracer_provider.tracer("my-app")
60
+ runner = Agents::Runner.with_agents(triage, billing, support)
61
+
62
+ Agents::Instrumentation.install(runner, tracer: tracer)
63
+ ```
64
+
65
+ That's it. Every `runner.run(...)` call now produces OTel spans.
66
+
67
+ ## Span Hierarchy
68
+
69
+ ```
70
+ root (agents.run)
71
+ ├── agent.Calculator # container span per agent (no model attr)
72
+ │ ├── agents.run.generation # GENERATION: model, tokens, I/O
73
+ │ ├── agents.run.tool.add # TOOL: arguments + result
74
+ │ └── agents.run.generation # second LLM call after tool result
75
+ ├── agent.Support # after handoff
76
+ │ └── agents.run.generation
77
+ └── agents.run.handoff # point event on root span
78
+ ```
79
+
80
+ **Only GENERATION spans carry `gen_ai.request.model`**. This prevents Langfuse from double-counting costs when it sums token usage across spans with a model attribute.
81
+
82
+ ## Configuration Options
83
+
84
+ ### `trace_name`
85
+
86
+ Custom name for the root span (default: `"agents.run"`):
87
+
88
+ ```ruby
89
+ Agents::Instrumentation.install(runner,
90
+ tracer: tracer,
91
+ trace_name: "customer_support.run"
92
+ )
93
+ ```
94
+
95
+ Child spans derive their names: `customer_support.run.generation`, `customer_support.run.tool.add_numbers`, etc.
96
+
97
+ ### `span_attributes`
98
+
99
+ Static attributes applied to the root span:
100
+
101
+ ```ruby
102
+ Agents::Instrumentation.install(runner,
103
+ tracer: tracer,
104
+ span_attributes: {
105
+ "langfuse.trace.tags" => '["production","v2"]',
106
+ "langfuse.session.id" => session_id
107
+ }
108
+ )
109
+ ```
110
+
111
+ ### `attribute_provider`
112
+
113
+ A lambda that receives the context wrapper and returns dynamic attributes:
114
+
115
+ ```ruby
116
+ Agents::Instrumentation.install(runner,
117
+ tracer: tracer,
118
+ attribute_provider: ->(ctx) {
119
+ {
120
+ "langfuse.user.id" => ctx.context[:user_id].to_s,
121
+ "langfuse.session.id" => ctx.context[:session_id].to_s
122
+ }
123
+ }
124
+ )
125
+ ```
126
+
127
+ ## Langfuse Integration
128
+
129
+ ### Endpoint and Authentication
130
+
131
+ Langfuse accepts OTel traces at `{LANGFUSE_HOST}/api/public/otel/v1/traces`. Authentication uses HTTP Basic with your public and secret keys:
132
+
133
+ ```ruby
134
+ require "base64"
135
+
136
+ langfuse_host = ENV["LANGFUSE_HOST"] # e.g. "https://cloud.langfuse.com"
137
+ langfuse_pk = ENV["LANGFUSE_PUBLIC_KEY"]
138
+ langfuse_sk = ENV["LANGFUSE_SECRET_KEY"]
139
+
140
+ auth_token = Base64.strict_encode64("#{langfuse_pk}:#{langfuse_sk}")
141
+
142
+ OpenTelemetry::SDK.configure do |c|
143
+ c.add_span_processor(
144
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
145
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
146
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
147
+ headers: { "Authorization" => "Basic #{auth_token}" }
148
+ )
149
+ )
150
+ )
151
+ end
152
+ ```
153
+
154
+ ### Attribute Mapping
155
+
156
+ The instrumentation sets Langfuse-specific attributes that map to the Langfuse UI:
157
+
158
+ | Attribute | Set On | Langfuse Display |
159
+ |-----------|--------|-----------------|
160
+ | `langfuse.trace.input` | Root span | Trace input (top of page) |
161
+ | `langfuse.trace.output` | Root span | Trace output (top of page) |
162
+ | `langfuse.observation.input` | All spans | Observation input (sidebar click) |
163
+ | `langfuse.observation.output` | All spans | Observation output (sidebar click) |
164
+ | `langfuse.observation.type` | Tool spans | `"tool"` type indicator |
165
+ | `langfuse.user.id` | Root span (via attribute_provider) | User filter/display |
166
+ | `langfuse.session.id` | Root span (via attribute_provider) | Session grouping |
167
+ | `langfuse.trace.tags` | Root span (via span_attributes) | Trace tags |
168
+ | `gen_ai.request.model` | Generation spans only | Model name + cost calculation |
169
+ | `gen_ai.usage.input_tokens` | Generation spans | Token usage |
170
+ | `gen_ai.usage.output_tokens` | Generation spans | Token usage |
171
+
172
+ ### EU vs US Cloud
173
+
174
+ - **US**: `https://cloud.langfuse.com`
175
+ - **EU**: `https://eu.cloud.langfuse.com`
176
+
177
+ Set `LANGFUSE_HOST` accordingly. Self-hosted instances use your own URL.
178
+
179
+ ## Complete Example
180
+
181
+ ```ruby
182
+ require "agents"
183
+ require "agents/instrumentation"
184
+ require "opentelemetry-sdk"
185
+ require "opentelemetry-exporter-otlp"
186
+ require "base64"
187
+
188
+ # --- Configure Agents ---
189
+ Agents.configure do |config|
190
+ config.openai_api_key = ENV["OPENAI_API_KEY"]
191
+ config.default_model = "gpt-4o-mini"
192
+ end
193
+
194
+ # --- Configure OTel with Langfuse ---
195
+ langfuse_host = ENV.fetch("LANGFUSE_HOST", "https://cloud.langfuse.com")
196
+ auth_token = Base64.strict_encode64(
197
+ "#{ENV["LANGFUSE_PUBLIC_KEY"]}:#{ENV["LANGFUSE_SECRET_KEY"]}"
198
+ )
199
+
200
+ OpenTelemetry::SDK.configure do |c|
201
+ c.add_span_processor(
202
+ OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
203
+ OpenTelemetry::Exporter::OTLP::Exporter.new(
204
+ endpoint: "#{langfuse_host}/api/public/otel/v1/traces",
205
+ headers: { "Authorization" => "Basic #{auth_token}" }
206
+ )
207
+ )
208
+ )
209
+ end
210
+
211
+ tracer = OpenTelemetry.tracer_provider.tracer("my-app")
212
+
213
+ # --- Build agents ---
214
+ triage = Agents::Agent.new(name: "Triage", instructions: "Route users...")
215
+ billing = Agents::Agent.new(name: "Billing", instructions: "Handle billing...")
216
+ support = Agents::Agent.new(name: "Support", instructions: "Technical support...")
217
+
218
+ triage.register_handoffs(billing, support)
219
+ billing.register_handoffs(triage)
220
+ support.register_handoffs(triage)
221
+
222
+ # --- Create runner with instrumentation ---
223
+ runner = Agents::Runner.with_agents(triage, billing, support)
224
+
225
+ Agents::Instrumentation.install(runner,
226
+ tracer: tracer,
227
+ trace_name: "customer_support",
228
+ attribute_provider: ->(ctx) {
229
+ {
230
+ "langfuse.user.id" => ctx.context[:user_id].to_s,
231
+ "langfuse.session.id" => ctx.context[:session_id].to_s
232
+ }
233
+ }
234
+ )
235
+
236
+ # --- Run conversations ---
237
+ result = runner.run("I have a billing question",
238
+ context: { user_id: "user_123", session_id: "sess_456" })
239
+
240
+ puts result.output
241
+
242
+ # Ensure spans are flushed before exit
243
+ at_exit { OpenTelemetry.tracer_provider.force_flush }
244
+ ```
245
+
246
+ ## Troubleshooting
247
+
248
+ ### "undefined" values in Langfuse
249
+
250
+ Langfuse renders empty string attributes as "undefined". The instrumentation guards against this by not setting attributes when values are nil or empty. If you see "undefined", check that your agents are producing output content.
251
+
252
+ ### Double-counted costs
253
+
254
+ If token costs appear inflated, verify that `gen_ai.request.model` is only set on GENERATION spans, not on container or root spans. The built-in instrumentation handles this correctly. If you set custom `span_attributes` that include `gen_ai.request.model`, costs will be double-counted.
255
+
256
+ ### Empty spans / missing data
257
+
258
+ - Ensure `opentelemetry-sdk` is installed (not just `opentelemetry-api`)
259
+ - Call `OpenTelemetry.tracer_provider.force_flush` before process exit
260
+ - Verify your OTLP endpoint is reachable and credentials are correct
261
+ - Check that `Agents::Instrumentation.install` returns the runner (returns nil if OTel is unavailable)
262
+
263
+ ### Spans not appearing in Langfuse
264
+
265
+ - Verify the endpoint includes `/api/public/otel/v1/traces`
266
+ - Check that the Authorization header uses `Basic` (not `Bearer`) with base64-encoded `pk:sk`
267
+ - Use `BatchSpanProcessor` for production; `SimpleSpanProcessor` can be useful for debugging
268
+ - **SSL CRL errors on Ruby 3.4+**: The OTLP exporter silently fails when SSL certificate revocation list (CRL) checks fail. The exporter reports SUCCESS but no data arrives. Fix by passing `ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE` to the exporter in development, or ensure your system CA certificates are up to date
data/docs/guides.md CHANGED
@@ -18,3 +18,4 @@ Practical guides for building real-world applications with the AI Agents library
18
18
  - **[State Persistence](guides/state-persistence.html)** - Managing conversation state and context across sessions and processes
19
19
  - **[Structured Output](guides/structured-output.html)** - Enforcing JSON schema validation for reliable agent responses
20
20
  - **[Custom Request Headers](guides/request-headers.html)** - Adding custom HTTP headers for authentication, tracking, and provider-specific features
21
+ - **[OpenTelemetry Instrumentation](guides/instrumentation.html)** - Trace agent execution with Langfuse and other OTel backends
@@ -3,8 +3,13 @@
3
3
 
4
4
  require "json"
5
5
  require "readline"
6
+ require "securerandom"
6
7
  require_relative "../../lib/agents"
7
8
  require_relative "agents_factory"
9
+ require_relative "../../lib/agents/instrumentation"
10
+ require "opentelemetry-sdk"
11
+ require "opentelemetry-exporter-otlp"
12
+ require "base64"
8
13
 
9
14
  # Simple ISP Customer Support Demo
10
15
  class ISPSupportDemo
@@ -27,10 +32,15 @@ class ISPSupportDemo
27
32
  # Setup real-time callbacks for UI feedback
28
33
  setup_callbacks
29
34
 
30
- @context = {}
35
+ # Setup OpenTelemetry instrumentation with Langfuse
36
+ setup_instrumentation
37
+
38
+ @session_id = SecureRandom.uuid
39
+ @context = { session_id: @session_id }
31
40
  @current_status = ""
32
41
 
33
42
  puts green("🏢 Welcome to ISP Customer Support!")
43
+ puts dim_text("Session ID: #{@session_id}")
34
44
  puts dim_text("Type '/help' for commands or 'exit' to quit.")
35
45
  puts
36
46
  end
@@ -89,6 +99,33 @@ class ISPSupportDemo
89
99
 
90
100
  private
91
101
 
102
+ def setup_instrumentation
103
+ host = ENV["LANGFUSE_HOST"]
104
+ pub_key = ENV["LANGFUSE_PUBLIC_KEY"]
105
+ sec_key = ENV["LANGFUSE_SECRET_KEY"]
106
+ unless host && pub_key && sec_key
107
+ return puts dim_text("⚠️ Langfuse env vars not set — running without instrumentation.")
108
+ end
109
+
110
+ configure_otel_exporter(host, pub_key, sec_key)
111
+ tracer = OpenTelemetry.tracer_provider.tracer("isp-support-demo")
112
+ Agents::Instrumentation.install(@runner, tracer: tracer, trace_name: "isp-support")
113
+ puts green("📡 Langfuse instrumentation enabled — traces → #{host}")
114
+ end
115
+
116
+ def configure_otel_exporter(host, pub_key, sec_key)
117
+ endpoint = "#{host}/api/public/otel/v1/traces"
118
+ auth = Base64.strict_encode64("#{pub_key}:#{sec_key}")
119
+ otlp = OpenTelemetry::Exporter::OTLP::Exporter.new(
120
+ endpoint: endpoint,
121
+ headers: { "Authorization" => "Basic #{auth}" },
122
+ ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE
123
+ )
124
+ OpenTelemetry::SDK.configure do |c|
125
+ c.add_span_processor(OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(otlp))
126
+ end
127
+ end
128
+
92
129
  def setup_callbacks
93
130
  @callback_messages = []
94
131
 
@@ -132,6 +169,7 @@ class ISPSupportDemo
132
169
  case input.downcase
133
170
  when "exit", "quit"
134
171
  dump_context_and_quit
172
+ flush_traces
135
173
  puts "👋 Goodbye!"
136
174
  :exit
137
175
  when "/help"
@@ -155,6 +193,13 @@ class ISPSupportDemo
155
193
  end
156
194
  end
157
195
 
196
+ def flush_traces
197
+ OpenTelemetry.tracer_provider.force_flush
198
+ puts dim_text("📡 Traces flushed to Langfuse.")
199
+ rescue StandardError
200
+ # OTel not configured — nothing to flush
201
+ end
202
+
158
203
  def dump_context_and_quit
159
204
  project_root = File.expand_path("../..", __dir__)
160
205
  tmp_directory = File.join(project_root, "tmp")
@@ -50,7 +50,9 @@ module Agents
50
50
  tool_start: [],
51
51
  tool_complete: [],
52
52
  agent_thinking: [],
53
- agent_handoff: []
53
+ agent_handoff: [],
54
+ llm_call_complete: [],
55
+ chat_created: []
54
56
  }
55
57
  end
56
58
 
@@ -164,6 +166,31 @@ module Agents
164
166
  self
165
167
  end
166
168
 
169
+ # Register a callback for LLM call completion events.
170
+ # Called after each LLM call completes with model and token usage info.
171
+ #
172
+ # @param block [Proc] Callback block that receives (agent_name, model, response, context_wrapper)
173
+ # @return [self] For method chaining
174
+ def on_llm_call_complete(&block)
175
+ return self unless block
176
+
177
+ @callbacks_mutex.synchronize { @callbacks[:llm_call_complete] << block }
178
+ self
179
+ end
180
+
181
+ # Register a callback for chat created events.
182
+ # Called when a RubyLLM Chat object is created or reconfigured after handoff.
183
+ # Useful for registering per-message hooks (e.g. on_end_message) on the chat.
184
+ #
185
+ # @param block [Proc] Callback block that receives (chat, agent_name, model, context_wrapper)
186
+ # @return [self] For method chaining
187
+ def on_chat_created(&block)
188
+ return self unless block
189
+
190
+ @callbacks_mutex.synchronize { @callbacks[:chat_created] << block }
191
+ self
192
+ end
193
+
167
194
  private
168
195
 
169
196
  # Build agent registry from provided agents only.
@@ -20,13 +20,20 @@ module Agents
20
20
  tool_complete
21
21
  agent_thinking
22
22
  agent_handoff
23
+ llm_call_complete
24
+ chat_created
23
25
  ].freeze
24
26
 
25
27
  def initialize(callbacks = {})
26
28
  @callbacks = callbacks.dup.freeze
27
29
  end
28
30
 
29
- # Generic method to emit any callback event type
31
+ # Generic method to emit any callback event type.
32
+ # Handles arity-aware dispatch: lambdas with strict arity receive only the
33
+ # arguments they expect (extra trailing args are sliced off), while procs
34
+ # and blocks (which have flexible arity) receive all arguments.
35
+ # This ensures backwards compatibility when new arguments (e.g. context_wrapper)
36
+ # are appended to existing callback signatures.
30
37
  #
31
38
  # @param event_type [Symbol] The type of event to emit
32
39
  # @param args [Array] Arguments to pass to callbacks
@@ -34,7 +41,8 @@ module Agents
34
41
  callback_list = @callbacks[event_type] || []
35
42
 
36
43
  callback_list.each do |callback|
37
- callback.call(*args)
44
+ safe_args = arity_safe_args(callback, args)
45
+ callback.call(*safe_args)
38
46
  rescue StandardError => e
39
47
  # Log callback errors but don't let them crash execution
40
48
  warn "Callback error for #{event_type}: #{e.message}"
@@ -53,5 +61,22 @@ module Agents
53
61
  emit(event_type, *args)
54
62
  end
55
63
  end
64
+
65
+ private
66
+
67
+ # Returns args sliced to fit the callback's accepted parameter count.
68
+ #
69
+ # Non-lambda procs/blocks silently ignore extra args, so they always get all args.
70
+ # Lambdas enforce strict argument counts and will raise ArgumentError on extras —
71
+ # even lambdas with optional params (e.g. ->(a, b, c = nil) {}) have a max.
72
+ # We inspect #parameters to compute the max and slice accordingly.
73
+ # Lambdas with a *rest parameter accept unlimited args, so we pass everything.
74
+ def arity_safe_args(callback, args)
75
+ return args unless callback.lambda?
76
+ return args if callback.parameters.any? { |type, _| type == :rest }
77
+
78
+ max = callback.parameters.count { |type, _| %i[req opt].include?(type) }
79
+ args.first(max)
80
+ end
56
81
  end
57
82
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ module Instrumentation
5
+ # OpenTelemetry attribute name constants for LLM observability.
6
+ # These follow the GenAI semantic conventions and Langfuse's OTel attribute mapping.
7
+ #
8
+ # @see https://langfuse.com/integrations/native/opentelemetry#property-mapping
9
+ module Constants
10
+ # Span names
11
+ SPAN_RUN = "agents.run"
12
+ SPAN_LLM_CALL = "agents.llm_call"
13
+ SPAN_TOOL = "agents.tool.%s"
14
+ EVENT_HANDOFF = "agents.handoff"
15
+
16
+ # GenAI semantic conventions (ONLY on generation spans)
17
+ ATTR_GEN_AI_REQUEST_MODEL = "gen_ai.request.model"
18
+ ATTR_GEN_AI_PROVIDER = "gen_ai.provider.name"
19
+ ATTR_GEN_AI_USAGE_INPUT = "gen_ai.usage.input_tokens"
20
+ ATTR_GEN_AI_USAGE_OUTPUT = "gen_ai.usage.output_tokens"
21
+
22
+ # Langfuse trace-level attributes
23
+ ATTR_LANGFUSE_USER_ID = "langfuse.user.id"
24
+ ATTR_LANGFUSE_SESSION_ID = "langfuse.session.id"
25
+ ATTR_LANGFUSE_TRACE_TAGS = "langfuse.trace.tags"
26
+ ATTR_LANGFUSE_TRACE_INPUT = "langfuse.trace.input"
27
+ ATTR_LANGFUSE_TRACE_OUTPUT = "langfuse.trace.output"
28
+
29
+ # Langfuse observation-level attributes
30
+ ATTR_LANGFUSE_OBS_TYPE = "langfuse.observation.type"
31
+ ATTR_LANGFUSE_OBS_INPUT = "langfuse.observation.input"
32
+ ATTR_LANGFUSE_OBS_OUTPUT = "langfuse.observation.output"
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,339 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agents
6
+ module Instrumentation
7
+ # Produces OTel spans for agent execution, compatible with Langfuse.
8
+ #
9
+ # Span hierarchy:
10
+ # root (<trace_name>)
11
+ # ├── agent.<name> ← container per agent (no gen_ai.request.model)
12
+ # │ ├── .generation ← GENERATION with model + tokens
13
+ # │ └── .tool.<name> ← TOOL observation
14
+ # └── .handoff ← point event on root
15
+ #
16
+ # Only GENERATION spans carry gen_ai.request.model to avoid Langfuse double-counting costs.
17
+ # Tracing state lives in context[:__otel_tracing], unique per run (thread-safe).
18
+ class TracingCallbacks
19
+ include Constants
20
+
21
+ def initialize(tracer:, trace_name: SPAN_RUN, span_attributes: {}, attribute_provider: nil)
22
+ @tracer = tracer
23
+ @trace_name = trace_name
24
+ @llm_span_name = "#{trace_name}.generation"
25
+ @tool_span_name = "#{trace_name}.tool.%s"
26
+ @agent_span_name = "#{trace_name}.agent.%s"
27
+ @handoff_event_name = "#{trace_name}.handoff"
28
+ @span_attributes = span_attributes
29
+ @attribute_provider = attribute_provider
30
+ end
31
+
32
+ def on_run_start(agent_name, input, context_wrapper)
33
+ attributes = build_root_attributes(agent_name, input, context_wrapper)
34
+
35
+ root_span = @tracer.start_span(@trace_name, attributes: attributes)
36
+ root_context = OpenTelemetry::Trace.context_with_span(root_span)
37
+
38
+ store_tracing_state(context_wrapper,
39
+ root_span: root_span,
40
+ root_context: root_context,
41
+ current_tool_span: nil,
42
+ current_agent_name: nil,
43
+ current_agent_span: nil,
44
+ current_agent_context: nil)
45
+ end
46
+
47
+ def on_agent_thinking(agent_name, input, context_wrapper)
48
+ tracing = tracing_state(context_wrapper)
49
+ return unless tracing
50
+
51
+ tracing[:pending_llm_input] = input.to_s
52
+
53
+ return if tracing[:current_agent_name] == agent_name
54
+
55
+ start_agent_span(tracing, agent_name)
56
+ end
57
+
58
+ # No-op: LLM spans are handled by on_end_message hook (see on_chat_created).
59
+ # Kept because the callback interface requires it.
60
+ def on_llm_call_complete(_agent_name, _model, _response, _context_wrapper); end
61
+
62
+ def on_agent_complete(_agent_name, _result, _error, context_wrapper)
63
+ tracing = tracing_state(context_wrapper)
64
+ return unless tracing
65
+
66
+ finish_agent_span(tracing)
67
+ end
68
+
69
+ def on_chat_created(chat, agent_name, model, context_wrapper)
70
+ tracing = tracing_state(context_wrapper)
71
+ return unless tracing
72
+
73
+ chat.on_end_message do |message|
74
+ handle_end_message(chat, agent_name, model, message, context_wrapper)
75
+ end
76
+ end
77
+
78
+ def on_tool_start(tool_name, args, context_wrapper)
79
+ tracing = tracing_state(context_wrapper)
80
+ return unless tracing
81
+
82
+ span_name = format(@tool_span_name, tool_name)
83
+ attributes = {
84
+ ATTR_LANGFUSE_OBS_TYPE => "tool",
85
+ ATTR_LANGFUSE_OBS_INPUT => serialize_output(args)
86
+ }
87
+
88
+ parent = handoff_tool?(tool_name) ? tracing[:root_context] : parent_context(tracing)
89
+ tool_span = @tracer.start_span(
90
+ span_name,
91
+ with_parent: parent,
92
+ attributes: attributes
93
+ )
94
+
95
+ tracing[:current_tool_span] = tool_span
96
+ end
97
+
98
+ def on_tool_complete(_tool_name, result, context_wrapper)
99
+ tracing = tracing_state(context_wrapper)
100
+ return unless tracing
101
+
102
+ tool_span = tracing[:current_tool_span]
103
+ return unless tool_span
104
+
105
+ tool_span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, serialize_output(result))
106
+ tool_span.finish
107
+ tracing[:current_tool_span] = nil
108
+ end
109
+
110
+ def on_agent_handoff(from_agent, to_agent, reason, context_wrapper)
111
+ tracing = tracing_state(context_wrapper)
112
+ return unless tracing
113
+
114
+ tracing[:root_span]&.add_event(
115
+ @handoff_event_name,
116
+ attributes: {
117
+ "handoff.from" => from_agent,
118
+ "handoff.to" => to_agent,
119
+ "handoff.reason" => reason.to_s
120
+ }
121
+ )
122
+ end
123
+
124
+ def on_run_complete(_agent_name, result, context_wrapper)
125
+ tracing = tracing_state(context_wrapper)
126
+ return unless tracing
127
+
128
+ finish_dangling_spans(tracing)
129
+
130
+ root_span = tracing[:root_span]
131
+ return unless root_span
132
+
133
+ set_run_output_attributes(root_span, result)
134
+ set_run_error_status(root_span, result)
135
+
136
+ root_span.finish
137
+ cleanup_tracing_state(context_wrapper)
138
+ end
139
+
140
+ private
141
+
142
+ def handle_end_message(chat, _agent_name, model, message, context_wrapper)
143
+ return unless message.respond_to?(:role) && message.role == :assistant
144
+
145
+ tracing = tracing_state(context_wrapper)
146
+ return unless tracing
147
+
148
+ input = format_chat_messages(chat)
149
+ attrs = {}
150
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input
151
+ llm_span = @tracer.start_span(@llm_span_name, with_parent: parent_context(tracing), attributes: attrs)
152
+
153
+ llm_span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, model) if model
154
+ set_llm_response_attributes(llm_span, message)
155
+
156
+ output = llm_output_text(message)
157
+ tracing[:last_agent_output] = output unless output.empty?
158
+
159
+ llm_span.finish
160
+ end
161
+
162
+ def finish_dangling_spans(tracing)
163
+ if tracing[:current_tool_span]
164
+ tracing[:current_tool_span].finish
165
+ tracing[:current_tool_span] = nil
166
+ end
167
+ finish_agent_span(tracing)
168
+ end
169
+
170
+ def set_run_output_attributes(root_span, result)
171
+ return unless result.respond_to?(:output)
172
+
173
+ output_text = serialize_output(result.output)
174
+ return if output_text.empty?
175
+
176
+ root_span.set_attribute(ATTR_LANGFUSE_TRACE_OUTPUT, output_text)
177
+ root_span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, output_text)
178
+ end
179
+
180
+ def set_run_error_status(root_span, result)
181
+ return unless result.respond_to?(:error)
182
+
183
+ error = result.error
184
+ return unless error
185
+
186
+ root_span.record_exception(error)
187
+ root_span.status = OpenTelemetry::Trace::Status.error(error.message)
188
+ end
189
+
190
+ def set_llm_response_attributes(span, response)
191
+ if response.respond_to?(:input_tokens) && response.input_tokens
192
+ span.set_attribute(ATTR_GEN_AI_USAGE_INPUT, response.input_tokens)
193
+ end
194
+ if response.respond_to?(:output_tokens) && response.output_tokens
195
+ span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT, response.output_tokens)
196
+ end
197
+ output = llm_output_text(response)
198
+ span.set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, output) unless output.empty?
199
+ end
200
+
201
+ # Falls back to formatting tool calls when response has no text content,
202
+ # and uses .to_json for Hash/Array (structured output) to avoid Ruby's .to_s format.
203
+ def llm_output_text(response)
204
+ return format_tool_calls(response) unless response.respond_to?(:content)
205
+
206
+ content = response.content
207
+ return format_tool_calls(response) if content.nil?
208
+
209
+ text = content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
210
+ return format_tool_calls(response) if text.empty?
211
+
212
+ text
213
+ end
214
+
215
+ # Excludes the last message (current response) — returns what was sent to the LLM.
216
+ def format_chat_messages(chat)
217
+ return nil unless chat.respond_to?(:messages)
218
+
219
+ messages = chat.messages
220
+ return nil if messages.nil? || messages.empty?
221
+
222
+ messages[0...-1].map { |m| format_single_message(m) }.to_json
223
+ end
224
+
225
+ def format_single_message(msg)
226
+ text = serialize_content(msg.content)
227
+ text = append_tool_calls(msg, text)
228
+ { role: msg.role.to_s, content: text }
229
+ end
230
+
231
+ def serialize_content(content)
232
+ content.is_a?(Hash) || content.is_a?(Array) ? content.to_json : content.to_s
233
+ end
234
+
235
+ def append_tool_calls(msg, text)
236
+ return text unless msg.role == :assistant && msg.respond_to?(:tool_calls) && msg.tool_calls&.any?
237
+
238
+ calls = msg.tool_calls.values.map { |tc| "#{tc.name}(#{tc.arguments.to_json})" }.join(", ")
239
+ text.empty? ? "Tool calls: #{calls}" : "#{text}\nTool calls: #{calls}"
240
+ end
241
+
242
+ def serialize_output(value)
243
+ value.is_a?(Hash) || value.is_a?(Array) ? value.to_json : value.to_s
244
+ end
245
+
246
+ def format_tool_calls(response)
247
+ return "" unless response.respond_to?(:tool_calls) && response.tool_calls&.any?
248
+
249
+ calls = response.tool_calls.values.map do |tc|
250
+ "#{tc.name}(#{serialize_output(tc.arguments)})"
251
+ end
252
+ "Tool calls: #{calls.join(", ")}"
253
+ end
254
+
255
+ def start_agent_span(tracing, agent_name)
256
+ finish_agent_span(tracing) # close previous agent span if missed
257
+
258
+ span_name = format(@agent_span_name, agent_name)
259
+ attrs = { "agent.name" => agent_name }
260
+ input = tracing[:pending_llm_input]
261
+ attrs[ATTR_LANGFUSE_OBS_INPUT] = input if input && !input.empty?
262
+
263
+ agent_span = @tracer.start_span(span_name,
264
+ with_parent: tracing[:root_context],
265
+ attributes: attrs)
266
+ agent_context = OpenTelemetry::Trace.context_with_span(agent_span)
267
+
268
+ tracing[:current_agent_name] = agent_name
269
+ tracing[:current_agent_span] = agent_span
270
+ tracing[:current_agent_context] = agent_context
271
+ tracing[:last_agent_output] = nil
272
+ end
273
+
274
+ def finish_agent_span(tracing)
275
+ return unless tracing[:current_agent_span]
276
+
277
+ last_output = tracing[:last_agent_output]
278
+ if last_output && !last_output.empty?
279
+ tracing[:current_agent_span].set_attribute(ATTR_LANGFUSE_OBS_OUTPUT, last_output)
280
+ end
281
+
282
+ tracing[:current_agent_span].finish
283
+ tracing[:current_agent_name] = nil
284
+ tracing[:current_agent_span] = nil
285
+ tracing[:current_agent_context] = nil
286
+ tracing[:last_agent_output] = nil
287
+ end
288
+
289
+ def parent_context(tracing)
290
+ tracing[:current_agent_context] || tracing[:root_context]
291
+ end
292
+
293
+ def handoff_tool?(tool_name)
294
+ tool_name.to_s.start_with?("handoff_to_")
295
+ end
296
+
297
+ def build_root_attributes(agent_name, input, context_wrapper)
298
+ attributes = @span_attributes.dup
299
+ apply_session_id(attributes, context_wrapper)
300
+ apply_input(attributes, input)
301
+ attributes["agent.name"] = agent_name
302
+ apply_dynamic_attributes(attributes, context_wrapper)
303
+ attributes
304
+ end
305
+
306
+ def apply_session_id(attributes, context_wrapper)
307
+ session_id = context_wrapper&.context&.dig(:session_id)&.to_s
308
+ attributes[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id && !session_id.empty?
309
+ end
310
+
311
+ def apply_input(attributes, input)
312
+ serialized_input = serialize_output(input)
313
+ return if serialized_input.empty?
314
+
315
+ attributes[ATTR_LANGFUSE_TRACE_INPUT] = serialized_input
316
+ attributes[ATTR_LANGFUSE_OBS_INPUT] = serialized_input
317
+ end
318
+
319
+ def apply_dynamic_attributes(attributes, context_wrapper)
320
+ return unless @attribute_provider
321
+
322
+ dynamic_attrs = @attribute_provider.call(context_wrapper)
323
+ attributes.merge!(dynamic_attrs) if dynamic_attrs.is_a?(Hash)
324
+ end
325
+
326
+ def store_tracing_state(context_wrapper, **state)
327
+ context_wrapper.context[:__otel_tracing] = state
328
+ end
329
+
330
+ def tracing_state(context_wrapper)
331
+ context_wrapper&.context&.dig(:__otel_tracing)
332
+ end
333
+
334
+ def cleanup_tracing_state(context_wrapper)
335
+ context_wrapper.context.delete(:__otel_tracing)
336
+ end
337
+ end
338
+ end
339
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "instrumentation/constants"
4
+ require_relative "instrumentation/tracing_callbacks"
5
+
6
+ module Agents
7
+ # Optional OpenTelemetry instrumentation for the ai-agents gem.
8
+ # Emits OTel spans for LLM calls, tool executions, and agent handoffs
9
+ # that render correctly in Langfuse and other OTel-compatible backends.
10
+ #
11
+ # The gem only emits spans — the consumer configures the OTel exporter
12
+ # and provides a tracer. The opentelemetry-api gem is NOT declared as a
13
+ # dependency; consumers must include it in their own bundle.
14
+ #
15
+ # @example Basic usage
16
+ # require 'agents/instrumentation'
17
+ #
18
+ # tracer = OpenTelemetry.tracer_provider.tracer('my_app')
19
+ # runner = Agents::Runner.with_agents(triage, billing, support)
20
+ #
21
+ # Agents::Instrumentation.install(runner, tracer: tracer)
22
+ #
23
+ # @example With custom trace name
24
+ # Agents::Instrumentation.install(runner,
25
+ # tracer: tracer,
26
+ # trace_name: 'customer_support.run'
27
+ # )
28
+ #
29
+ # @example With Langfuse attributes
30
+ # Agents::Instrumentation.install(runner,
31
+ # tracer: tracer,
32
+ # span_attributes: { 'langfuse.trace.tags' => ['v2'].to_json },
33
+ # attribute_provider: ->(ctx) {
34
+ # { 'langfuse.user.id' => ctx.context[:account_id].to_s }
35
+ # }
36
+ # )
37
+ module Instrumentation
38
+ INSTALL_MUTEX = Mutex.new
39
+ private_constant :INSTALL_MUTEX
40
+
41
+ INSTRUMENTATION_FLAG_IVAR = :@__agents_otel_instrumentation_installed
42
+ private_constant :INSTRUMENTATION_FLAG_IVAR
43
+
44
+ # Install OTel tracing on a runner via callbacks.
45
+ # No-op if opentelemetry-api is not available.
46
+ # Idempotent per runner instance: first install wins.
47
+ #
48
+ # Session grouping: set `context[:session_id]` when calling `runner.run()`.
49
+ # TracingCallbacks automatically reads it per-request and sets `langfuse.session.id`.
50
+ #
51
+ # @param runner [Agents::AgentRunner] The runner to instrument
52
+ # @param tracer [OpenTelemetry::Trace::Tracer] OTel tracer instance
53
+ # @param trace_name [String] Name for the root span (default: "agents.run")
54
+ # @param span_attributes [Hash] Static attributes applied to the root span
55
+ # @param attribute_provider [Proc, nil] Lambda receiving context_wrapper, returning dynamic attributes
56
+ # @return [Agents::AgentRunner, nil] The runner (for chaining), or nil if OTel is unavailable
57
+ def self.install(runner, tracer:, trace_name: Constants::SPAN_RUN, span_attributes: {},
58
+ attribute_provider: nil)
59
+ return unless otel_available?
60
+
61
+ INSTALL_MUTEX.synchronize do
62
+ return runner if instrumentation_installed?(runner)
63
+
64
+ callbacks = TracingCallbacks.new(
65
+ tracer: tracer,
66
+ trace_name: trace_name,
67
+ span_attributes: span_attributes,
68
+ attribute_provider: attribute_provider
69
+ )
70
+
71
+ register_callbacks(runner, callbacks)
72
+ mark_instrumentation_installed(runner)
73
+ end
74
+ runner
75
+ end
76
+
77
+ # Callback event types that are forwarded from the runner to TracingCallbacks.
78
+ TRACED_EVENTS = CallbackManager::EVENT_TYPES
79
+ private_constant :TRACED_EVENTS
80
+
81
+ # Register all tracing callback handlers on the runner.
82
+ def self.register_callbacks(runner, callbacks)
83
+ TRACED_EVENTS.each do |event|
84
+ runner.public_send(:"on_#{event}") { |*args| callbacks.public_send(:"on_#{event}", *args) }
85
+ end
86
+ end
87
+ private_class_method :register_callbacks
88
+
89
+ def self.instrumentation_installed?(runner)
90
+ runner.instance_variable_get(INSTRUMENTATION_FLAG_IVAR)
91
+ end
92
+ private_class_method :instrumentation_installed?
93
+
94
+ def self.mark_instrumentation_installed(runner)
95
+ runner.instance_variable_set(INSTRUMENTATION_FLAG_IVAR, true)
96
+ end
97
+ private_class_method :mark_instrumentation_installed
98
+
99
+ # Check if the opentelemetry-api gem is available.
100
+ #
101
+ # @return [Boolean] true if opentelemetry-api can be loaded
102
+ def self.otel_available?
103
+ require "opentelemetry-api"
104
+ true
105
+ rescue LoadError
106
+ false
107
+ end
108
+ end
109
+ end
data/lib/agents/runner.rb CHANGED
@@ -104,6 +104,8 @@ module Agents
104
104
  apply_headers(chat, current_headers)
105
105
  configure_chat_for_agent(chat, current_agent, context_wrapper, replace: false)
106
106
  restore_conversation_history(chat, context_wrapper)
107
+ input_already_in_history = last_message_matches?(chat, input)
108
+ context_wrapper.callback_manager.emit_chat_created(chat, current_agent.name, current_agent.model, context_wrapper)
107
109
 
108
110
  loop do
109
111
  current_turn += 1
@@ -112,16 +114,24 @@ module Agents
112
114
  # Get response from LLM (RubyLLM handles tool execution with halting based handoff detection)
113
115
  result = if current_turn == 1
114
116
  # Emit agent thinking event for initial message
115
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input)
116
- chat.ask(input)
117
+ context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, input, context_wrapper)
118
+ # If conversation history already ends with this user message (e.g. passed
119
+ # in via context from an external system), use complete to avoid duplicating it.
120
+ input_already_in_history ? chat.complete : chat.ask(input)
117
121
  else
118
122
  # Emit agent thinking event for continuation
119
- context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)")
123
+ context_wrapper.callback_manager.emit_agent_thinking(current_agent.name, "(continuing conversation)",
124
+ context_wrapper)
120
125
  chat.complete
121
126
  end
122
127
  response = result
123
128
  track_usage(response, context_wrapper)
124
129
 
130
+ # Emit LLM call complete event with model and response for instrumentation
131
+ context_wrapper.callback_manager.emit_llm_call_complete(
132
+ current_agent.name, current_agent.model, response, context_wrapper
133
+ )
134
+
125
135
  # Check for handoff via RubyLLM's halt mechanism
126
136
  if response.is_a?(RubyLLM::Tool::Halt) && context_wrapper.context[:pending_handoff]
127
137
  handoff_info = context_wrapper.context.delete(:pending_handoff)
@@ -155,7 +165,8 @@ module Agents
155
165
  context_wrapper.callback_manager.emit_agent_complete(current_agent.name, nil, nil, context_wrapper)
156
166
 
157
167
  # Emit agent handoff event
158
- context_wrapper.callback_manager.emit_agent_handoff(current_agent.name, next_agent.name, "handoff")
168
+ context_wrapper.callback_manager.emit_agent_handoff(current_agent.name, next_agent.name, "handoff",
169
+ context_wrapper)
159
170
 
160
171
  # Switch to new agent - store agent name for persistence
161
172
  current_agent = next_agent
@@ -166,6 +177,9 @@ module Agents
166
177
  agent_headers = Helpers::Headers.normalize(current_agent.headers)
167
178
  current_headers = Helpers::Headers.merge(agent_headers, runtime_headers)
168
179
  apply_headers(chat, current_headers)
180
+ context_wrapper.callback_manager.emit_chat_created(
181
+ chat, current_agent.name, current_agent.model, context_wrapper
182
+ )
169
183
 
170
184
  # Force the new agent to respond to the conversation context
171
185
  # This ensures the user gets a response from the new agent
@@ -409,6 +423,21 @@ module Agents
409
423
  chat
410
424
  end
411
425
 
426
+ # Check if the last message in the chat already matches the user's input.
427
+ # This happens when an external system (e.g. Chatwoot) includes the current
428
+ # user message in the conversation history passed via context.
429
+ #
430
+ # TODO: This .to_s == .to_s comparison is a best-effort safety net and is
431
+ # brittle for edge cases (trailing whitespace, Hash/JSON round-tripping).
432
+ # The proper fix is for callers to pass nil when input is already present
433
+ # in conversation history, similar to the handoff continuation path.
434
+ def last_message_matches?(chat, input)
435
+ return false unless input && chat.respond_to?(:messages)
436
+
437
+ last_msg = chat.messages.last
438
+ last_msg && last_msg.role == :user && last_msg.content.to_s == input.to_s
439
+ end
440
+
412
441
  def apply_headers(chat, headers)
413
442
  return if headers.empty?
414
443
 
@@ -47,14 +47,14 @@ module Agents
47
47
  def call(args)
48
48
  tool_context = ToolContext.new(run_context: @context_wrapper)
49
49
 
50
- @context_wrapper.callback_manager.emit_tool_start(@tool.name, args)
50
+ @context_wrapper.callback_manager.emit_tool_start(@tool.name, args, @context_wrapper)
51
51
 
52
52
  begin
53
53
  result = @tool.execute(tool_context, **args.transform_keys(&:to_sym))
54
- @context_wrapper.callback_manager.emit_tool_complete(@tool.name, result)
54
+ @context_wrapper.callback_manager.emit_tool_complete(@tool.name, result, @context_wrapper)
55
55
  result
56
56
  rescue StandardError => e
57
- @context_wrapper.callback_manager.emit_tool_complete(@tool.name, "ERROR: #{e.message}")
57
+ @context_wrapper.callback_manager.emit_tool_complete(@tool.name, "ERROR: #{e.message}", @context_wrapper)
58
58
  raise
59
59
  end
60
60
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Agents
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ai-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shivam Mishra
@@ -32,6 +32,7 @@ extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
34
  - ".claude/commands/bump-version.md"
35
+ - ".env.example"
35
36
  - ".rspec"
36
37
  - ".rubocop.yml"
37
38
  - CHANGELOG.md
@@ -58,6 +59,7 @@ files:
58
59
  - docs/concepts/tools.md
59
60
  - docs/guides.md
60
61
  - docs/guides/agent-as-tool-pattern.md
62
+ - docs/guides/instrumentation.md
61
63
  - docs/guides/multi-agent-systems.md
62
64
  - docs/guides/rails-integration.md
63
65
  - docs/guides/request-headers.md
@@ -106,6 +108,9 @@ files:
106
108
  - lib/agents/helpers.rb
107
109
  - lib/agents/helpers/headers.rb
108
110
  - lib/agents/helpers/message_extractor.rb
111
+ - lib/agents/instrumentation.rb
112
+ - lib/agents/instrumentation/constants.rb
113
+ - lib/agents/instrumentation/tracing_callbacks.rb
109
114
  - lib/agents/result.rb
110
115
  - lib/agents/run_context.rb
111
116
  - lib/agents/runner.rb