riffer 0.34.0 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +18 -0
- data/README.md +0 -1
- data/docs/01_OVERVIEW.md +0 -1
- data/docs/03_AGENTS.md +40 -0
- data/docs/10_CONFIGURATION.md +0 -17
- data/docs/16_TRACING.md +4 -0
- data/lib/riffer/agent/run.rb +79 -70
- data/lib/riffer/agent.rb +20 -13
- data/lib/riffer/config.rb +0 -45
- data/lib/riffer/guardrails/runner.rb +14 -35
- data/lib/riffer/providers/amazon_bedrock.rb +7 -1
- data/lib/riffer/providers/anthropic.rb +8 -1
- data/lib/riffer/providers/base.rb +16 -68
- data/lib/riffer/providers/gemini.rb +5 -1
- data/lib/riffer/providers/open_ai.rb +10 -1
- data/lib/riffer/providers/open_router.rb +10 -1
- data/lib/riffer/tools/runtime.rb +23 -34
- data/lib/riffer/version.rb +1 -1
- data/sig/_private/opentelemetry.rbs +3 -6
- data/sig/generated/riffer/agent/run.rbs +41 -28
- data/sig/generated/riffer/agent.rbs +16 -9
- data/sig/generated/riffer/config.rbs +0 -33
- data/sig/generated/riffer/guardrails/runner.rbs +5 -10
- data/sig/generated/riffer/providers/base.rbs +4 -16
- data/sig/generated/riffer/tools/runtime.rbs +12 -10
- metadata +5 -16
- data/docs/17_METRICS.md +0 -153
- data/lib/riffer/metrics/instruments.rb +0 -31
- data/lib/riffer/metrics/no_op.rb +0 -14
- data/lib/riffer/metrics/otel.rb +0 -80
- data/lib/riffer/metrics.rb +0 -94
- data/sig/generated/riffer/metrics/instruments.rbs +0 -15
- data/sig/generated/riffer/metrics/no_op.rbs +0 -10
- data/sig/generated/riffer/metrics/otel.rbs +0 -48
- data/sig/generated/riffer/metrics.rbs +0 -72
- data/sig/manual/riffer/metrics/no_op.rbs +0 -5
- data/sig/manual/riffer/metrics.rbs +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 491260200757bf1d17b920f099012d771328fd333d46bb325f53dca863bcf204
|
|
4
|
+
data.tar.gz: 072ffcdf47b2aa115a3c7743cb77d4f238d18b926f0a53eb6b45a28148b8b2be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 97bf5aeed05fe9b591ae5a2bcf7c59d6990a46de1695fa4ff929823ead08b23372f35c4efb389a7e2f769a2a15dbad4dcb35bb1421e57c1fe5291216390b176c
|
|
7
|
+
data.tar.gz: 421d381a7d8c0a1be0c017a2c347096fa452d48d512706c474e82b5d90097e245d9f7680ed6745c10aac31502cdfe47efc56ae885dbd9e4b52b3b7e2803b7c10
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.36.0](https://github.com/janeapp/riffer/compare/riffer/v0.35.0...riffer/v0.36.0) (2026-07-03)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### ⚠ BREAKING CHANGES
|
|
12
|
+
|
|
13
|
+
* config.metrics, the Riffer::Metrics module, and Riffer::Metrics::Otel are removed. Hosts wiring a metrics backend must drop that configuration.
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* remove the metrics primitive ([#347](https://github.com/janeapp/riffer/issues/347)) ([70a838e](https://github.com/janeapp/riffer/commit/70a838e9d49da12f89886c6e6f7bca35f427f77c))
|
|
18
|
+
|
|
19
|
+
## [0.35.0](https://github.com/janeapp/riffer/compare/riffer/v0.34.0...riffer/v0.35.0) (2026-06-25)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
### Features
|
|
23
|
+
|
|
24
|
+
* per-call tags: for Agent#generate and #stream ([#339](https://github.com/janeapp/riffer/issues/339)) ([ddfda89](https://github.com/janeapp/riffer/commit/ddfda8929ca3b594415f827957d8c91104387317))
|
|
25
|
+
|
|
8
26
|
## [0.34.0](https://github.com/janeapp/riffer/compare/riffer/v0.33.0...riffer/v0.34.0) (2026-06-22)
|
|
9
27
|
|
|
10
28
|
|
data/README.md
CHANGED
|
@@ -63,7 +63,6 @@ For comprehensive documentation, see the [docs](docs/) directory:
|
|
|
63
63
|
- [MCP](docs/14_MCP.md) - Integrating third-party MCP servers
|
|
64
64
|
- [Serialization](docs/15_SERIALIZATION.md) - Persisting and transferring agent definitions
|
|
65
65
|
- [Tracing](docs/16_TRACING.md) - OpenTelemetry span contract and host wiring
|
|
66
|
-
- [Metrics](docs/17_METRICS.md) - OpenTelemetry metric contract and host wiring
|
|
67
66
|
- [Providers](docs/providers/01_PROVIDERS.md) - LLM provider adapters
|
|
68
67
|
|
|
69
68
|
### API Reference
|
data/docs/01_OVERVIEW.md
CHANGED
|
@@ -132,7 +132,6 @@ Response
|
|
|
132
132
|
- [Tools](06_TOOLS.md) - Creating tools
|
|
133
133
|
- [Configuration](10_CONFIGURATION.md) - Global configuration
|
|
134
134
|
- [Tracing](16_TRACING.md) - OpenTelemetry span contract and host wiring
|
|
135
|
-
- [Metrics](17_METRICS.md) - OpenTelemetry metric contract and host wiring
|
|
136
135
|
- [Evals](11_EVALS.md) - Evaluating agent quality
|
|
137
136
|
- [Guardrails](12_GUARDRAILS.md) - Input/output validation
|
|
138
137
|
- [Skills](13_SKILLS.md) - Packaged agent capabilities
|
data/docs/03_AGENTS.md
CHANGED
|
@@ -317,6 +317,46 @@ agent.generate('Hello')
|
|
|
317
317
|
|
|
318
318
|
When `config:` is supplied, the class-level configuration is ignored for that instance.
|
|
319
319
|
|
|
320
|
+
## Per-Call Tags
|
|
321
|
+
|
|
322
|
+
`#generate` and `#stream` accept an optional `tags:` hash — a flat map of attribution labels scoped to that single call (for cost/usage attribution, filtering audit logs, slicing telemetry). It is **per-call only**.
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
agent.generate("Summarize this ticket.",
|
|
326
|
+
tags: {team: "growth", feature: "summarizer", user_id: "u_abc123"})
|
|
327
|
+
|
|
328
|
+
agent.stream("...", tags: {team: "growth", environment: "production"})
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Keys and values may be `String` or `Symbol`; both are stringified, and entries with a `nil` value are dropped. Passing a non-`Hash` raises `Riffer::ArgumentError`. An omitted or empty `tags:` is a complete no-op.
|
|
332
|
+
|
|
333
|
+
Tags propagate to **two** places:
|
|
334
|
+
|
|
335
|
+
1. The provider's native per-request metadata field (see the mapping below).
|
|
336
|
+
2. Observability — stamped as `riffer.tag.<key>` on **every** span the call emits (`invoke_agent`, `chat`, `execute_tool`, `execute_guardrail`). See [Tracing](16_TRACING.md).
|
|
337
|
+
|
|
338
|
+
### Reserved key: `user_id`
|
|
339
|
+
|
|
340
|
+
`user_id` is a reserved tag. Beyond appearing like any other tag, it maps to the provider's native end-user identifier where one exists (see the table).
|
|
341
|
+
|
|
342
|
+
### Provider mapping
|
|
343
|
+
|
|
344
|
+
| Provider | Native request field | `user_id` handling |
|
|
345
|
+
| --------------------- | ----------------------------- | --------------------------------------- |
|
|
346
|
+
| Amazon Bedrock | `request_metadata` (all tags) | Ordinary entry; no dedicated user field |
|
|
347
|
+
| OpenAI / Azure OpenAI | `metadata` (all tags) | Also mapped to `safety_identifier` |
|
|
348
|
+
| OpenRouter | `metadata` (all tags) | Also mapped to `user` |
|
|
349
|
+
| Anthropic | `metadata.user_id` **only** | The only tag forwarded |
|
|
350
|
+
| Gemini | _(none — observability only)_ | Tag only; no request field |
|
|
351
|
+
|
|
352
|
+
**Anthropic silently drops non-`user_id` tags.** The Messages API has no free-form request-metadata field — only `metadata.user_id`. So for Anthropic, `user_id` is forwarded as `metadata: {user_id: …}` and **every other tag is dropped from the request** (it still appears on spans). This is intentional.
|
|
353
|
+
|
|
354
|
+
**Gemini is observability-only.** Riffer's Gemini adapter targets the Gemini Developer API (`generativelanguage.googleapis.com`), whose `generateContent` request has **no** `labels` field — sending unknown fields is rejected. So tags are **not** added to the Gemini request; they propagate to spans only. Native request labels (`labels`, lowercase `[a-z0-9_-]`, ≤63 chars each) are a Vertex AI feature and would arrive with a future Vertex adapter.
|
|
355
|
+
|
|
356
|
+
### Provider limits (not enforced)
|
|
357
|
+
|
|
358
|
+
Riffer does not validate tag count, key/value length, or charset — it forwards what you give it and lets the provider reject anything out of bounds. Known limits: Bedrock `requestMetadata` ≤16 entries, key/value ≤256 chars; OpenAI / OpenRouter `metadata` ≤16 pairs (OpenRouter: 64-char keys, 512-char values).
|
|
359
|
+
|
|
320
360
|
## Expand Your Agent
|
|
321
361
|
|
|
322
362
|
| Goal | Feature | Guide |
|
data/docs/10_CONFIGURATION.md
CHANGED
|
@@ -119,23 +119,6 @@ end
|
|
|
119
119
|
| `capture_messages` | Opt-in capture of full message content on LLM-call spans (`gen_ai.input.messages`, `gen_ai.output.messages`, `gen_ai.system_instructions`) as GenAI-semconv JSON. Defaults to `false` — message content routinely carries sensitive data. File attachments serialize as metadata-only stubs (media type and name, never bytes), and riffer applies no size limit of its own — cap oversized attributes with the OTEL SDK attribute length limits. |
|
|
120
120
|
| `backend` | The backend riffer routes spans through. Assign `Riffer::Tracing::Otel.build` (pass `provider:` to override the global tracer provider — e.g. an in-memory provider in tests), or any object satisfying the duck-typed contract (`in_span` / `current_context` / `with_context`) to route into a non-OTEL system (e.g. Datadog APM). Defaults to `nil` — a no-op. Raises `Riffer::ArgumentError` unless the value is `nil` or responds to `in_span`. See [Tracing → Routing to a non-OpenTelemetry backend](16_TRACING.md#routing-to-a-non-opentelemetry-backend). |
|
|
121
121
|
|
|
122
|
-
### Metrics
|
|
123
|
-
|
|
124
|
-
Metrics-related global configuration lives under `config.metrics`, **independent** of `config.tracing` — each has its own kill switch and its own backend, so you can run one signal without the other. Riffer records measurements only through the backend you assign to `config.metrics.backend` — there is **no auto-detection**. OpenTelemetry is a built-in backend you opt into explicitly with `Riffer::Metrics::Otel.build`; with no backend set every measurement is a silent no-op, and riffer carries no OTEL gem dependency either way. The metrics API and SDK are separate, still-experimental (pre-1.0) gems from the traces API.
|
|
125
|
-
|
|
126
|
-
```ruby
|
|
127
|
-
Riffer.configure do |config|
|
|
128
|
-
config.metrics.enabled = ENV.fetch("RIFFER_METRICS_ENABLED", "true")
|
|
129
|
-
# Opt into OpenTelemetry (uses the global meter provider):
|
|
130
|
-
config.metrics.backend = Riffer::Metrics::Otel.build
|
|
131
|
-
end
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
| Option | Description |
|
|
135
|
-
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
-
| `enabled` | The kill switch, consulted on every measurement — flipping it at runtime takes effect immediately, short-circuiting to a no-op ahead of the backend. Accepts booleans or `'true'`/`'false'`/`'1'`/`'0'`. Defaults to `true`. |
|
|
137
|
-
| `backend` | The backend riffer routes measurements through. Assign `Riffer::Metrics::Otel.build` (pass `provider:` to override the global meter provider — e.g. an in-memory provider in tests), or any object responding to `record_histogram` to route into a non-OTEL system (e.g. DogStatsD). Defaults to `nil` — a no-op. Raises `Riffer::ArgumentError` unless the value is `nil` or responds to `record_histogram`. See [Metrics → Routing to a non-OpenTelemetry backend](17_METRICS.md#routing-to-a-non-opentelemetry-backend). |
|
|
138
|
-
|
|
139
122
|
### Pricing
|
|
140
123
|
|
|
141
124
|
Configure per-model token prices and riffer computes the cost of each LLM call onto its [`TokenUsage`](08_MESSAGES.md#token-usage-semantics). Riffer ships **no** price table — so an unconfigured model simply carries no cost (`token_usage.cost` is `nil`).
|
data/docs/16_TRACING.md
CHANGED
|
@@ -93,6 +93,10 @@ Every attribute a span can carry is listed below, including the conditional ones
|
|
|
93
93
|
|
|
94
94
|
The contract promise is: **when present**, a key carries the documented meaning and type. It is _not_ a promise that every key appears on every span.
|
|
95
95
|
|
|
96
|
+
### Per-call tags (`riffer.tag.*`)
|
|
97
|
+
|
|
98
|
+
Any tags passed to `#generate` / `#stream` via `tags:` are stamped on **all four** span types as `riffer.tag.<key>` (string), so the per-span tables below omit them. They appear on every span the tagged call emits and are absent otherwise. Example: `tags: {team: "growth"}` adds `riffer.tag.team` → `"growth"` to the `invoke_agent`, `chat`, `execute_tool`, and `execute_guardrail` spans. See [Per-Call Tags](03_AGENTS.md#per-call-tags) for the full surface and the per-provider request-metadata mapping.
|
|
99
|
+
|
|
96
100
|
## `invoke_agent {agent}` — the run span
|
|
97
101
|
|
|
98
102
|
`INTERNAL`. One per call to `Agent#generate` or `Agent#stream`. The span name suffix is the agent's identifier (e.g. `invoke_agent weather-agent`).
|
data/lib/riffer/agent/run.rb
CHANGED
|
@@ -10,63 +10,60 @@ module Riffer::Agent::Run
|
|
|
10
10
|
# for prompt/files semantics.
|
|
11
11
|
#
|
|
12
12
|
#--
|
|
13
|
-
#: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
|
|
14
|
-
def generate(agent:, prompt: nil, files: nil)
|
|
13
|
+
#: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
|
|
14
|
+
def generate(agent:, prompt: nil, files: nil, tags: {})
|
|
15
15
|
append_user_message(agent, prompt, files: files)
|
|
16
|
-
run_loop(agent)
|
|
16
|
+
run_loop(agent, tags: tags)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Runs the streaming loop for the given agent. See Riffer::Agent#stream
|
|
20
20
|
# for prompt/files semantics.
|
|
21
21
|
#
|
|
22
22
|
#--
|
|
23
|
-
#: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
24
|
-
def stream(agent:, prompt: nil, files: nil)
|
|
23
|
+
#: (agent: Riffer::Agent, ?prompt: String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
24
|
+
def stream(agent:, prompt: nil, files: nil, tags: {})
|
|
25
25
|
append_user_message(agent, prompt, files: files)
|
|
26
26
|
# The enumerator body runs in its own fiber, where the fiber-local OTEL
|
|
27
|
-
# context is empty — capture here so the run span parents to the caller's
|
|
27
|
+
# context is empty — capture here so the run span parents to the caller's
|
|
28
|
+
# trace. tags ride as an ordinary argument captured in the closure, so they
|
|
29
|
+
# cross the fiber boundary without any re-propagation.
|
|
28
30
|
trace_context = Riffer::Tracing.current_context
|
|
29
31
|
Enumerator.new do |stream_yielder|
|
|
30
|
-
Riffer::Tracing.with_context(trace_context) { run_loop(agent, stream_yielder: stream_yielder) }
|
|
32
|
+
Riffer::Tracing.with_context(trace_context) { run_loop(agent, tags: tags, stream_yielder: stream_yielder) }
|
|
31
33
|
end
|
|
32
34
|
end
|
|
33
35
|
|
|
34
36
|
private
|
|
35
37
|
|
|
38
|
+
# Both +generate+ and +stream+ funnel here, so this is the single place raw
|
|
39
|
+
# +tags+ are normalized. The clean <tt>String => String</tt> map is then
|
|
40
|
+
# threaded to every span builder in the run as +riffer.tag.*+ and to each
|
|
41
|
+
# provider call (via +merged_model_options+) for native request-metadata
|
|
42
|
+
# mapping.
|
|
36
43
|
#--
|
|
37
|
-
#: (Riffer::Agent, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
|
|
38
|
-
def run_loop(agent, stream_yielder: nil)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
record_run_outcome(span, response)
|
|
45
|
-
response
|
|
46
|
-
rescue => error
|
|
47
|
-
# The backend records the exception and error status on the re-raise;
|
|
48
|
-
# error.type is the one semconv attribute it doesn't set.
|
|
49
|
-
span.set_attribute("error.type", error.class.name)
|
|
50
|
-
raise
|
|
51
|
-
end
|
|
44
|
+
#: (Riffer::Agent, ?tags: Hash[(String | Symbol), untyped]?, ?stream_yielder: Enumerator::Yielder?) -> Riffer::Agent::Response
|
|
45
|
+
def run_loop(agent, tags: {}, stream_yielder: nil)
|
|
46
|
+
tags = normalize_tags(tags)
|
|
47
|
+
Riffer::Tracing.in_span("invoke_agent #{agent.class.identifier}", attributes: run_span_attributes(agent, tags), kind: :internal) do |span|
|
|
48
|
+
response = execute_run(agent, stream_yielder, tags)
|
|
49
|
+
record_run_outcome(span, response)
|
|
50
|
+
response
|
|
52
51
|
rescue => error
|
|
53
|
-
# The
|
|
54
|
-
#
|
|
55
|
-
|
|
52
|
+
# The backend records the exception and error status on the re-raise;
|
|
53
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
54
|
+
span.set_attribute("error.type", error.class.name)
|
|
56
55
|
raise
|
|
57
|
-
ensure
|
|
58
|
-
Riffer::Metrics::Instruments::OPERATION_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: run_metric_attributes(agent, error_type))
|
|
59
56
|
end
|
|
60
57
|
end
|
|
61
58
|
|
|
62
59
|
#--
|
|
63
|
-
#: (Riffer::Agent, Enumerator::Yielder?) -> Riffer::Agent::Response
|
|
64
|
-
def execute_run(agent, stream_yielder)
|
|
60
|
+
#: (Riffer::Agent, Enumerator::Yielder?, ?Hash[String, String]) -> Riffer::Agent::Response
|
|
61
|
+
def execute_run(agent, stream_yielder, tags = {})
|
|
65
62
|
all_modifications = [] #: Array[Riffer::Guardrails::Modification]
|
|
66
63
|
run_usage = nil #: Riffer::Providers::TokenUsage?
|
|
67
64
|
run_steps = 0
|
|
68
65
|
|
|
69
|
-
run_before_guardrails(agent, stream_yielder, all_modifications) do |tripwire|
|
|
66
|
+
run_before_guardrails(agent, stream_yielder, all_modifications, tags) do |tripwire|
|
|
70
67
|
return tripwire_response(agent, stream_yielder, tripwire, all_modifications, steps: run_steps)
|
|
71
68
|
end
|
|
72
69
|
|
|
@@ -84,16 +81,16 @@ module Riffer::Agent::Run
|
|
|
84
81
|
step = agent.session.steps
|
|
85
82
|
|
|
86
83
|
reason = catch(:riffer_interrupt) do
|
|
87
|
-
execute_pending_tool_calls(agent)
|
|
84
|
+
execute_pending_tool_calls(agent, tags)
|
|
88
85
|
|
|
89
86
|
loop do
|
|
90
|
-
response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder) : call_llm(agent)
|
|
87
|
+
response = stream_yielder ? accumulate_streamed_response(agent, stream_yielder, tags) : call_llm(agent, tags)
|
|
91
88
|
step += 1
|
|
92
89
|
run_steps += 1
|
|
93
90
|
track_token_usage(agent, response.token_usage)
|
|
94
91
|
run_usage = sum_usage(run_usage, response.token_usage)
|
|
95
92
|
|
|
96
|
-
processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications) do |tripwire|
|
|
93
|
+
processed_response = run_after_guardrails(agent, response, stream_yielder, all_modifications, tags) do |tripwire|
|
|
97
94
|
return tripwire_response(agent, stream_yielder, tripwire, all_modifications, token_usage: run_usage, steps: run_steps)
|
|
98
95
|
end
|
|
99
96
|
|
|
@@ -104,7 +101,7 @@ module Riffer::Agent::Run
|
|
|
104
101
|
max_steps = agent.config.max_steps
|
|
105
102
|
throw :riffer_interrupt, Riffer::Agent::INTERRUPT_MAX_STEPS if max_steps && step >= max_steps
|
|
106
103
|
|
|
107
|
-
execute_tool_calls(agent, processed_response)
|
|
104
|
+
execute_tool_calls(agent, processed_response, tags: tags)
|
|
108
105
|
end
|
|
109
106
|
|
|
110
107
|
return final_response(agent, all_modifications, token_usage: run_usage, steps: run_steps)
|
|
@@ -122,14 +119,14 @@ module Riffer::Agent::Run
|
|
|
122
119
|
end
|
|
123
120
|
|
|
124
121
|
#--
|
|
125
|
-
#: (Riffer::Agent, Enumerator::Yielder) -> Riffer::Messages::Assistant
|
|
126
|
-
def accumulate_streamed_response(agent, stream_yielder)
|
|
122
|
+
#: (Riffer::Agent, Enumerator::Yielder, ?Hash[String, String]) -> Riffer::Messages::Assistant
|
|
123
|
+
def accumulate_streamed_response(agent, stream_yielder, tags = {})
|
|
127
124
|
accumulated_content = ""
|
|
128
125
|
accumulated_tool_calls = [] #: Array[Riffer::Messages::Assistant::ToolCall]
|
|
129
126
|
accumulated_token_usage = nil #: Riffer::Providers::TokenUsage?
|
|
130
127
|
accumulated_finish_reason = nil #: Symbol?
|
|
131
128
|
|
|
132
|
-
call_llm_stream(agent).each do |event|
|
|
129
|
+
call_llm_stream(agent, tags).each do |event|
|
|
133
130
|
stream_yielder << event
|
|
134
131
|
|
|
135
132
|
case event
|
|
@@ -180,33 +177,33 @@ module Riffer::Agent::Run
|
|
|
180
177
|
end
|
|
181
178
|
|
|
182
179
|
#--
|
|
183
|
-
#: (Riffer::Agent) -> Riffer::Messages::Assistant
|
|
184
|
-
def call_llm(agent)
|
|
180
|
+
#: (Riffer::Agent, ?Hash[String, String]) -> Riffer::Messages::Assistant
|
|
181
|
+
def call_llm(agent, tags = {})
|
|
185
182
|
agent.provider.generate_text(
|
|
186
183
|
messages: agent.session.messages,
|
|
187
184
|
model: agent.model_name,
|
|
188
185
|
tools: effective_tools(agent),
|
|
189
|
-
**merged_model_options(agent)
|
|
186
|
+
**merged_model_options(agent, tags)
|
|
190
187
|
)
|
|
191
188
|
end
|
|
192
189
|
|
|
193
190
|
#--
|
|
194
|
-
#: (Riffer::Agent) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
195
|
-
def call_llm_stream(agent)
|
|
191
|
+
#: (Riffer::Agent, ?Hash[String, String]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
192
|
+
def call_llm_stream(agent, tags = {})
|
|
196
193
|
agent.provider.stream_text(
|
|
197
194
|
messages: agent.session.messages,
|
|
198
195
|
model: agent.model_name,
|
|
199
196
|
tools: effective_tools(agent),
|
|
200
|
-
**merged_model_options(agent)
|
|
197
|
+
**merged_model_options(agent, tags)
|
|
201
198
|
)
|
|
202
199
|
end
|
|
203
200
|
|
|
204
201
|
#--
|
|
205
|
-
#: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall]) -> void
|
|
206
|
-
def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls)
|
|
202
|
+
#: (Riffer::Agent, Riffer::Messages::Assistant, ?tool_calls: Array[Riffer::Messages::Assistant::ToolCall], ?tags: Hash[String, String]) -> void
|
|
203
|
+
def execute_tool_calls(agent, assistant_message, tool_calls: assistant_message.tool_calls, tags: {})
|
|
207
204
|
return if tool_calls.empty?
|
|
208
205
|
|
|
209
|
-
results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message)
|
|
206
|
+
results = agent.tool_runtime.execute(tool_calls, tools: effective_tools(agent), context: agent.context, assistant_message: assistant_message, tags: tags)
|
|
210
207
|
|
|
211
208
|
inject_discovered_tools(agent, results)
|
|
212
209
|
|
|
@@ -233,19 +230,19 @@ module Riffer::Agent::Run
|
|
|
233
230
|
end
|
|
234
231
|
|
|
235
232
|
#--
|
|
236
|
-
#: (Riffer::Agent) -> void
|
|
237
|
-
def execute_pending_tool_calls(agent)
|
|
233
|
+
#: (Riffer::Agent, ?Hash[String, String]) -> void
|
|
234
|
+
def execute_pending_tool_calls(agent, tags = {})
|
|
238
235
|
assistant_message, pending = agent.session.pending_tool_calls
|
|
239
|
-
execute_tool_calls(agent, assistant_message, tool_calls: pending) if assistant_message
|
|
236
|
+
execute_tool_calls(agent, assistant_message, tool_calls: pending, tags: tags) if assistant_message
|
|
240
237
|
end
|
|
241
238
|
|
|
242
239
|
#--
|
|
243
|
-
#: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> void
|
|
244
|
-
def run_before_guardrails(agent, stream_yielder, all_modifications)
|
|
240
|
+
#: (Riffer::Agent, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> void
|
|
241
|
+
def run_before_guardrails(agent, stream_yielder, all_modifications, tags = {})
|
|
245
242
|
guardrails = agent.config.guardrails_for(:before)
|
|
246
243
|
return if guardrails.empty?
|
|
247
244
|
|
|
248
|
-
runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context)
|
|
245
|
+
runner = Riffer::Guardrails::Runner.new(guardrails, phase: :before, context: agent.context, tags: tags)
|
|
249
246
|
processed_messages, tripwire, modifications = runner.run(agent.session.messages)
|
|
250
247
|
agent.session.set(processed_messages) unless tripwire
|
|
251
248
|
record_modifications!(stream_yielder, all_modifications, modifications)
|
|
@@ -253,12 +250,12 @@ module Riffer::Agent::Run
|
|
|
253
250
|
end
|
|
254
251
|
|
|
255
252
|
#--
|
|
256
|
-
#: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
|
|
257
|
-
def run_after_guardrails(agent, response, stream_yielder, all_modifications)
|
|
253
|
+
#: (Riffer::Agent, Riffer::Messages::Assistant, Enumerator::Yielder?, Array[Riffer::Guardrails::Modification], ?Hash[String, String]) { (Riffer::Guardrails::Tripwire) -> void } -> untyped
|
|
254
|
+
def run_after_guardrails(agent, response, stream_yielder, all_modifications, tags = {})
|
|
258
255
|
guardrails = agent.config.guardrails_for(:after)
|
|
259
256
|
return response if guardrails.empty?
|
|
260
257
|
|
|
261
|
-
runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context)
|
|
258
|
+
runner = Riffer::Guardrails::Runner.new(guardrails, phase: :after, context: agent.context, tags: tags)
|
|
262
259
|
processed_response, tripwire, modifications = runner.run(response, messages: agent.session.messages)
|
|
263
260
|
|
|
264
261
|
response_index = agent.session.messages.length
|
|
@@ -285,11 +282,16 @@ module Riffer::Agent::Run
|
|
|
285
282
|
discovered.empty? ? agent.tools : agent.tools + discovered
|
|
286
283
|
end
|
|
287
284
|
|
|
285
|
+
# +tags+ rides in the options hash as a curated key the providers extract for
|
|
286
|
+
# native request-metadata mapping (alongside +:structured_output+); it never
|
|
287
|
+
# reaches an SDK call verbatim. Span tagging is threaded separately to each
|
|
288
|
+
# builder.
|
|
288
289
|
#--
|
|
289
|
-
#: (Riffer::Agent) -> Hash[Symbol, untyped]
|
|
290
|
-
def merged_model_options(agent)
|
|
290
|
+
#: (Riffer::Agent, ?Hash[String, String]) -> Hash[Symbol, untyped]
|
|
291
|
+
def merged_model_options(agent, tags = {})
|
|
291
292
|
opts = agent.config.model_options.dup
|
|
292
293
|
opts[:structured_output] = agent.structured_output if agent.structured_output
|
|
294
|
+
opts[:tags] = tags unless tags.empty?
|
|
293
295
|
opts
|
|
294
296
|
end
|
|
295
297
|
|
|
@@ -329,27 +331,34 @@ module Riffer::Agent::Run
|
|
|
329
331
|
end
|
|
330
332
|
|
|
331
333
|
#--
|
|
332
|
-
#: (Riffer::Agent) -> Hash[String, untyped]
|
|
333
|
-
def run_span_attributes(agent)
|
|
334
|
+
#: (Riffer::Agent, ?Hash[String, String]) -> Hash[String, untyped]
|
|
335
|
+
def run_span_attributes(agent, tags = {})
|
|
334
336
|
{
|
|
335
337
|
"gen_ai.operation.name" => "invoke_agent",
|
|
336
338
|
"gen_ai.agent.name" => agent.class.identifier,
|
|
337
339
|
"gen_ai.provider.name" => agent.provider.class.semconv_provider_name,
|
|
338
340
|
"gen_ai.request.model" => agent.model_name
|
|
339
|
-
}
|
|
341
|
+
}.merge(tag_attributes(tags))
|
|
340
342
|
end
|
|
341
343
|
|
|
342
344
|
#--
|
|
343
|
-
#: (
|
|
344
|
-
def
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
345
|
+
#: (Hash[(String | Symbol), untyped]?) -> Hash[String, String]
|
|
346
|
+
def normalize_tags(tags)
|
|
347
|
+
return {} if tags.nil?
|
|
348
|
+
raise Riffer::ArgumentError, "tags: must be a Hash, got #{tags.class}" unless tags.is_a?(Hash)
|
|
349
|
+
|
|
350
|
+
result = {} #: Hash[String, String]
|
|
351
|
+
tags.each do |key, value|
|
|
352
|
+
next if value.nil?
|
|
353
|
+
result[key.to_s] = value.to_s
|
|
354
|
+
end
|
|
355
|
+
result
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
#--
|
|
359
|
+
#: (Hash[String, String]) -> Hash[String, String]
|
|
360
|
+
def tag_attributes(tags)
|
|
361
|
+
tags.transform_keys { |key| "riffer.tag.#{key}" }
|
|
353
362
|
end
|
|
354
363
|
|
|
355
364
|
#--
|
data/lib/riffer/agent.rb
CHANGED
|
@@ -169,16 +169,16 @@ class Riffer::Agent
|
|
|
169
169
|
|
|
170
170
|
# Generates a response using a new agent instance.
|
|
171
171
|
#--
|
|
172
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Riffer::Agent::Response
|
|
173
|
-
def self.generate(prompt = nil, files: nil, context: nil)
|
|
174
|
-
new(context: context).generate(prompt, files: files)
|
|
172
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
|
|
173
|
+
def self.generate(prompt = nil, files: nil, context: nil, tags: {})
|
|
174
|
+
new(context: context).generate(prompt, files: files, tags: tags)
|
|
175
175
|
end
|
|
176
176
|
|
|
177
177
|
# Streams a response using a new agent instance.
|
|
178
178
|
#--
|
|
179
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
180
|
-
def self.stream(prompt = nil, files: nil, context: nil)
|
|
181
|
-
new(context: context).stream(prompt, files: files)
|
|
179
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?context: Hash[Symbol, untyped]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
180
|
+
def self.stream(prompt = nil, files: nil, context: nil, tags: {})
|
|
181
|
+
new(context: context).stream(prompt, files: files, tags: tags)
|
|
182
182
|
end
|
|
183
183
|
|
|
184
184
|
# Reconstructs a runnable agent from a wire hash produced by +#to_h+.
|
|
@@ -288,22 +288,29 @@ class Riffer::Agent
|
|
|
288
288
|
# against the current session, resuming a persisted conversation or pending
|
|
289
289
|
# tool calls. +files:+ requires +prompt+.
|
|
290
290
|
#
|
|
291
|
+
# +tags:+ is an optional flat hash of attribution tags applied to this single
|
|
292
|
+
# call: they propagate to the provider's native request-metadata field, and
|
|
293
|
+
# are stamped as +riffer.tag.*+ on every span the call emits. See
|
|
294
|
+
# +docs/03_AGENTS.md+ for the per-provider mapping. The reserved key
|
|
295
|
+
# +user_id+ also maps to the provider's native user identifier where one
|
|
296
|
+
# exists.
|
|
297
|
+
#
|
|
291
298
|
#--
|
|
292
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Riffer::Agent::Response
|
|
293
|
-
def generate(prompt = nil, files: nil)
|
|
294
|
-
Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files)
|
|
299
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Riffer::Agent::Response
|
|
300
|
+
def generate(prompt = nil, files: nil, tags: {})
|
|
301
|
+
Riffer::Agent::Run.generate(agent: self, prompt: prompt, files: files, tags: tags)
|
|
295
302
|
end
|
|
296
303
|
|
|
297
304
|
# Streams a response from the agent, returning an +Enumerator+ of
|
|
298
|
-
# +Riffer::StreamEvents+. See +#generate+ for prompt/files semantics.
|
|
305
|
+
# +Riffer::StreamEvents+. See +#generate+ for prompt/files/tags semantics.
|
|
299
306
|
#
|
|
300
307
|
# Raises Riffer::ArgumentError if structured output is configured.
|
|
301
308
|
#
|
|
302
309
|
#--
|
|
303
|
-
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
304
|
-
def stream(prompt = nil, files: nil)
|
|
310
|
+
#: (?String?, ?files: Array[Hash[Symbol, untyped] | Riffer::Messages::FilePart]?, ?tags: Hash[(String | Symbol), untyped]) -> Enumerator[Riffer::StreamEvents::Base, void]
|
|
311
|
+
def stream(prompt = nil, files: nil, tags: {})
|
|
305
312
|
raise Riffer::ArgumentError, "Structured output is not supported with streaming. Use #generate instead." if @structured_output
|
|
306
|
-
Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files)
|
|
313
|
+
Riffer::Agent::Run.stream(agent: self, prompt: prompt, files: files, tags: tags)
|
|
307
314
|
end
|
|
308
315
|
|
|
309
316
|
# Interrupts the agent loop from an +on_message+ callback. Equivalent to
|
data/lib/riffer/config.rb
CHANGED
|
@@ -106,47 +106,6 @@ class Riffer::Config
|
|
|
106
106
|
end
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
-
# Metrics-related global configuration, independent of +config.tracing+ so a
|
|
110
|
-
# host can run one signal without the other.
|
|
111
|
-
class Metrics
|
|
112
|
-
# Whether riffer records OTEL metric instruments; defaults to +true+, a
|
|
113
|
-
# no-op until a host wires an OTEL metrics SDK.
|
|
114
|
-
attr_reader :enabled #: bool
|
|
115
|
-
|
|
116
|
-
# The backend riffer routes measurements through; defaults to +nil+, a no-op.
|
|
117
|
-
# Riffer auto-detects no backend; assigning one is opt-in.
|
|
118
|
-
attr_reader :backend #: untyped
|
|
119
|
-
|
|
120
|
-
#--
|
|
121
|
-
#: () -> void
|
|
122
|
-
def initialize
|
|
123
|
-
@enabled = true
|
|
124
|
-
@backend = nil
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Sets the enabled flag, coercing boolean-ish values so an env-var
|
|
128
|
-
# +"false"+ (truthy in Ruby) doesn't silently keep metrics on. Raises
|
|
129
|
-
# Riffer::ArgumentError on an unrecognized value.
|
|
130
|
-
#--
|
|
131
|
-
#: (untyped) -> void
|
|
132
|
-
def enabled=(value)
|
|
133
|
-
@enabled = Riffer::Helpers::Boolean.coerce(value, attribute: "enabled")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Sets the metrics backend riffer routes measurements through. Raises
|
|
137
|
-
# Riffer::ArgumentError unless the value is +nil+ or responds to
|
|
138
|
-
# +record_histogram+.
|
|
139
|
-
#--
|
|
140
|
-
#: (untyped) -> void
|
|
141
|
-
def backend=(value)
|
|
142
|
-
unless value.nil? || value.respond_to?(:record_histogram)
|
|
143
|
-
raise Riffer::ArgumentError, "metrics backend must respond to #record_histogram"
|
|
144
|
-
end
|
|
145
|
-
@backend = value
|
|
146
|
-
Riffer::Metrics.reset!
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
109
|
# Consumer-configured token pricing, keyed by +provider/model+ id. Riffer
|
|
151
110
|
# ships no price table, so an unconfigured model carries no cost.
|
|
152
111
|
class Pricing
|
|
@@ -306,9 +265,6 @@ class Riffer::Config
|
|
|
306
265
|
# Tracing-related global configuration.
|
|
307
266
|
attr_reader :tracing #: Riffer::Config::Tracing
|
|
308
267
|
|
|
309
|
-
# Metrics-related global configuration.
|
|
310
|
-
attr_reader :metrics #: Riffer::Config::Metrics
|
|
311
|
-
|
|
312
268
|
# Consumer-configured per-model token pricing.
|
|
313
269
|
attr_reader :pricing #: Riffer::Config::Pricing
|
|
314
270
|
|
|
@@ -357,7 +313,6 @@ class Riffer::Config
|
|
|
357
313
|
@tool_runtime = Riffer::Tools::Runtime::Inline.new
|
|
358
314
|
@skills = Skills.new
|
|
359
315
|
@tracing = Tracing.new
|
|
360
|
-
@metrics = Metrics.new
|
|
361
316
|
@pricing = Pricing.new
|
|
362
317
|
@message_id_strategy = :none
|
|
363
318
|
@experimental_history_healing = false
|
|
@@ -13,12 +13,16 @@ class Riffer::Guardrails::Runner
|
|
|
13
13
|
# The context passed to guardrails.
|
|
14
14
|
attr_reader :context #: untyped
|
|
15
15
|
|
|
16
|
+
# The normalized per-call tags, stamped as +riffer.tag.*+ on guardrail spans.
|
|
17
|
+
attr_reader :tags #: Hash[String, String]
|
|
18
|
+
|
|
16
19
|
#--
|
|
17
|
-
#: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped) -> void
|
|
18
|
-
def initialize(guardrail_configs, phase:, context: nil)
|
|
20
|
+
#: (Array[Hash[Symbol, untyped]], phase: Symbol, ?context: untyped, ?tags: Hash[String, String]) -> void
|
|
21
|
+
def initialize(guardrail_configs, phase:, context: nil, tags: {})
|
|
19
22
|
@guardrail_configs = guardrail_configs
|
|
20
23
|
@phase = phase
|
|
21
24
|
@context = context
|
|
25
|
+
@tags = tags
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
# Runs the guardrails sequentially. For the +:before+ phase +data+ is the
|
|
@@ -81,32 +85,15 @@ class Riffer::Guardrails::Runner
|
|
|
81
85
|
#--
|
|
82
86
|
#: (Riffer::Guardrail, untyped, messages: Array[Riffer::Messages::Base]?) -> Riffer::Guardrails::Result
|
|
83
87
|
def execute_guardrail(guardrail, data, messages:)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
result
|
|
89
|
-
rescue => error
|
|
90
|
-
# The backend records the exception and error status on the re-raise;
|
|
91
|
-
# error.type is the one semconv attribute it doesn't set.
|
|
92
|
-
span.set_attribute("error.type", error.class.name)
|
|
93
|
-
raise
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
#--
|
|
99
|
-
#: (Riffer::Guardrail) { () -> Riffer::Guardrails::Result } -> Riffer::Guardrails::Result
|
|
100
|
-
def instrument_guardrail(guardrail)
|
|
101
|
-
start = Riffer::Metrics.monotonic_now
|
|
102
|
-
error_type = nil #: String?
|
|
103
|
-
begin
|
|
104
|
-
yield
|
|
88
|
+
Riffer::Tracing.in_span("execute_guardrail #{guardrail.name}", attributes: guardrail_span_attributes(guardrail), kind: :internal) do |span|
|
|
89
|
+
result = run_guardrail_phase(guardrail, data, messages: messages)
|
|
90
|
+
record_guardrail_outcome(span, result)
|
|
91
|
+
result
|
|
105
92
|
rescue => error
|
|
106
|
-
|
|
93
|
+
# The backend records the exception and error status on the re-raise;
|
|
94
|
+
# error.type is the one semconv attribute it doesn't set.
|
|
95
|
+
span.set_attribute("error.type", error.class.name)
|
|
107
96
|
raise
|
|
108
|
-
ensure
|
|
109
|
-
Riffer::Metrics::Instruments::GUARDRAIL_DURATION.record(Riffer::Metrics.monotonic_now - start, attributes: guardrail_metric_attributes(guardrail, error_type))
|
|
110
97
|
end
|
|
111
98
|
end
|
|
112
99
|
|
|
@@ -129,15 +116,7 @@ class Riffer::Guardrails::Runner
|
|
|
129
116
|
{
|
|
130
117
|
"riffer.guardrail.name" => guardrail.name,
|
|
131
118
|
"riffer.guardrail.phase" => phase.to_s
|
|
132
|
-
}
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
#--
|
|
136
|
-
#: (Riffer::Guardrail, String?) -> Hash[String, untyped]
|
|
137
|
-
def guardrail_metric_attributes(guardrail, error_type)
|
|
138
|
-
attributes = guardrail_span_attributes(guardrail)
|
|
139
|
-
attributes["error.type"] = error_type if error_type
|
|
140
|
-
attributes
|
|
119
|
+
}.merge(tags.transform_keys { |key| "riffer.tag.#{key}" })
|
|
141
120
|
end
|
|
142
121
|
|
|
143
122
|
# A block is a handled outcome, so its span status stays unset — an error
|