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 +4 -4
- data/.env.example +6 -0
- data/CHANGELOG.md +19 -0
- data/CLAUDE.md +26 -0
- data/README.md +16 -0
- data/docs/guides/instrumentation.md +268 -0
- data/docs/guides.md +1 -0
- data/examples/isp-support/interactive.rb +46 -1
- data/lib/agents/agent_runner.rb +28 -1
- data/lib/agents/callback_manager.rb +27 -2
- data/lib/agents/instrumentation/constants.rb +35 -0
- data/lib/agents/instrumentation/tracing_callbacks.rb +339 -0
- data/lib/agents/instrumentation.rb +109 -0
- data/lib/agents/runner.rb +33 -4
- data/lib/agents/tool_wrapper.rb +3 -3
- data/lib/agents/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: df386be7e27f87111901954d72e4caa3e26a1d789ec113fbf9e2da9d2f87587e
|
|
4
|
+
data.tar.gz: f05b6827852966d0514abae61c7732a34ea92aeebe30855132ec8bee39c1e4c2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2180b6b495519d34ff4762cd027d6079fb39ad91117242f6f366779fd7360c7bbb55521761e151ad246eee3004363de9bf768ca953a8e69a1e39e2f2dfeb5345
|
|
7
|
+
data.tar.gz: 41dadb09fd62a2ce47b063c6b85685f1efa8be73dbee467e83ad69cdb32793926aaadacbce7bfa820960c0d5f35e57325a7078733c19bef0e1121076bc6a9002
|
data/.env.example
ADDED
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
|
-
|
|
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")
|
data/lib/agents/agent_runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
data/lib/agents/tool_wrapper.rb
CHANGED
|
@@ -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
|
data/lib/agents/version.rb
CHANGED
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.
|
|
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
|