smith-agents 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +230 -0
- data/docs/CONFIGURATION.md +5 -5
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +10 -4
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_completion.rb +27 -9
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +23 -20
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +2 -1
- data/lib/smith/workflow/deterministic_step.rb +14 -1
- data/lib/smith/workflow/dsl.rb +25 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +8 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +33 -1
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +9 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +26 -1
- data/lib/smith.rb +7 -2
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 06665b52b660baf1f846ebd104caec677044595bd4aaf71ecf6ac3120e18d478
|
|
4
|
+
data.tar.gz: '09dd67e047e0f2a3dc6afd0f6b320ab7d5b7f2f23460ddae9576860e7b2e1329'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 76771bcb4d58d45f110b16a590579a34f698f1e9e2b75cca7806a5c5864f50c8209afc4a258a587069c6dd97f6ae87c1d0a62d434afd4b415f04e199561e79a6
|
|
7
|
+
data.tar.gz: 5160511ae311634881c774fa8040d3b69f93171e20e76aca595821c9eb7a767c76d0165ffe562190b16f624a4ba2e3e272822b3a1cf6b852ae2d2af7d7521994
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,236 @@ All notable changes to Smith are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Smith is pre-1.0 and under active development; expect occasional contract tightening between minor versions until 1.0.
|
|
6
6
|
|
|
7
|
+
## [0.10.0] - 2026-08-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Last-serial-agent-execution attribution. A deterministic (`compute`) step can
|
|
12
|
+
now read the model and provider that actually served the most recent serial
|
|
13
|
+
`execute :agent` step via `DeterministicStep#last_agent_model` /
|
|
14
|
+
`#last_agent_provider` (symmetric with `#last_output`, which returns that
|
|
15
|
+
step's content). The value is the resolved model/provider after fallback
|
|
16
|
+
resolution, so a step that failed its primary and completed on a
|
|
17
|
+
`fallback_models` entry reports the model that actually ran, never the
|
|
18
|
+
configured primary. It is durable lifecycle state (persisted in `to_state` as
|
|
19
|
+
`last_agent_execution`, restored across crash/resume), not a trace scrape, and
|
|
20
|
+
is `nil` until a serial agent step runs. Backward compatible: pre-upgrade
|
|
21
|
+
states have no `last_agent_execution` key and restore to `nil`; old readers
|
|
22
|
+
slice off the unknown key.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Structured agent outputs recorded as session messages (Hash or Array
|
|
27
|
+
content) are now serialized to JSON at the RubyLLM provider boundary when
|
|
28
|
+
replayed to a later agent step. RubyLLM treats a Hash message content as
|
|
29
|
+
attachments and opens each value as a file, so replaying a prior structured
|
|
30
|
+
output to the next agent in a workflow session raised `Errno::ENOENT`. The
|
|
31
|
+
session store is unaffected: `last_output` and persisted `session_messages`
|
|
32
|
+
keep the raw structured value; only the provider-facing copy is serialized.
|
|
33
|
+
|
|
34
|
+
## [0.9.0] - 2026-08-03
|
|
35
|
+
|
|
36
|
+
### Upgrade notes
|
|
37
|
+
|
|
38
|
+
- Rollback after running fan-out branches under this version is a one-way
|
|
39
|
+
door for those runs: composite branch effects written with attribution
|
|
40
|
+
values (usage entries) or batch-correlated `tool_call_id` capture entries
|
|
41
|
+
fail an older gem's exact-key validation at reduction or recovery.
|
|
42
|
+
Drain in-flight composite runs before rolling the gem back; plain
|
|
43
|
+
checkpoint payloads are unaffected (restored pre-attribution documents
|
|
44
|
+
re-serialize byte-identically, and old readers slice off unknown keys).
|
|
45
|
+
- Hosts with a configured trace adapter see new output on upgrade without
|
|
46
|
+
any host change: one `:provider_call` line per provider attempt, one
|
|
47
|
+
`:cost` line per priced completed invocation, failed `:transition` lines
|
|
48
|
+
marked `outcome: :failed`, and ambient attribution keys (including
|
|
49
|
+
`execution_key`, the Smith persistence key) merged into every payload.
|
|
50
|
+
Identifier-only, but plan for the volume and shape change, especially
|
|
51
|
+
with `Smith::Trace::Logger` in production.
|
|
52
|
+
- Hosts with a `trace_fields` allowlist for `:transition` must add
|
|
53
|
+
`:outcome` (and the error keys they want) or failed transitions render
|
|
54
|
+
indistinguishable from successes under the allowlist.
|
|
55
|
+
- `Smith::Trace::Memory` is now bounded (default 10,000 entries, silent
|
|
56
|
+
drop with `dropped_count`); previously it accumulated without limit.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- Add `Smith::Attribution`, an immutable thread-local execution attribution
|
|
61
|
+
context (`execution_key`, `transition`, `from`, `to`, `branch_key`,
|
|
62
|
+
`round`). Workflow execution installs it per step (the `execution_key`
|
|
63
|
+
defaults to the persistence key of a persisted run), fan-out carries it
|
|
64
|
+
into branch threads and overlays the branch key, and evaluator-optimizer
|
|
65
|
+
rounds overlay the round index. Hosts can seed an outer scope with
|
|
66
|
+
`Smith::Attribution.with(execution_key: ...)` around non-persisted runs.
|
|
67
|
+
Restoration inside workflow execution rides `ThreadContextSnapshot`, which
|
|
68
|
+
now tracks the attribution thread key. Scope overlays are nil-ignoring
|
|
69
|
+
(`Context#merge`), but the per-step facts (`transition`, `from`, `to`)
|
|
70
|
+
are replaced verbatim, nil included (`Context#override`): a nested
|
|
71
|
+
child's `from`-less transition never inherits the parent step's `from`,
|
|
72
|
+
and the failed `:transition` trace keeps `from`/`to` present even when
|
|
73
|
+
nil for the same reason.
|
|
74
|
+
- Merge ambient attribution fields into every `Smith::Trace.record` payload.
|
|
75
|
+
Attribution keys are identifiers, not content: caller-supplied keys win on
|
|
76
|
+
conflict, the content policy is unaffected, and a configured
|
|
77
|
+
`trace_fields` allowlist stays authoritative (add attribution keys to an
|
|
78
|
+
allowlist to receive them). Disable with `Smith.config.trace_attribution =
|
|
79
|
+
false` (default true).
|
|
80
|
+
- Bound `Smith::Trace::Memory` (default 10,000 entries) with a
|
|
81
|
+
`dropped_count` reader and a `snapshot` method for readers racing
|
|
82
|
+
concurrent recording.
|
|
83
|
+
- Tag `Workflow::UsageEntry` with the ambient attribution at recording time:
|
|
84
|
+
new optional members `transition`, `branch_key`, `round`, and `attempt_id`.
|
|
85
|
+
All four are nil on entries restored from checkpoints written by earlier
|
|
86
|
+
Smith versions and are omitted from serialization when nil, so restored
|
|
87
|
+
pre-attribution documents re-serialize byte-identically (hosts that digest
|
|
88
|
+
whole persisted documents in exact-mutation proofs depend on this). A
|
|
89
|
+
rolled-back gem drops the new keys from plain checkpoint payloads
|
|
90
|
+
(`from_h` slices to known members). Composite branch effects are the
|
|
91
|
+
exception: effects written by this version from a fan-out branch carry
|
|
92
|
+
real attribution values, and an older gem's exact-key effects validation
|
|
93
|
+
rejects them, so see the upgrade notes below before rolling back.
|
|
94
|
+
`recorded_at` now carries microsecond precision (`iso8601(6)`) on new
|
|
95
|
+
entries. Recording symbolizes `transition`/`branch_key` Strings exactly
|
|
96
|
+
as `from_h` does on restore, so a host seeding String attribution through
|
|
97
|
+
`Smith::Attribution.with` gets entries equal to their restored form.
|
|
98
|
+
- Measure each provider attempt with a monotonic clock around the whole chat
|
|
99
|
+
completion (including any provider tool loop) and emit one
|
|
100
|
+
`:provider_call` trace per attempt (success or failure) carrying `model`,
|
|
101
|
+
`provider`, `duration_ms`, `attempt_id`, `attempt_index`, and `outcome`.
|
|
102
|
+
Every usage entry the attempt produced shares its `attempt_id` (an attempt
|
|
103
|
+
with an N-round tool loop records N entries): join there for the attempt's
|
|
104
|
+
single duration, never sum across entries. Gate with
|
|
105
|
+
`Smith.config.trace_provider_calls` (default true). `ProviderAttempt`
|
|
106
|
+
gains optional `attempt_id` and `duration_ms`.
|
|
107
|
+
- Add a public read-only `Workflow#usage_entries` (frozen copy under the
|
|
108
|
+
recording mutex) so hosts can diff usage across a step boundary without a
|
|
109
|
+
full `to_state` serialization; `to_state` and `snapshot_usage_entries` now
|
|
110
|
+
read the ledger under the same mutex, so a state written mid-fan-out never
|
|
111
|
+
captures a torn array.
|
|
112
|
+
- Thread real correlation identity through events: `Smith::Event#execution_id`
|
|
113
|
+
and `#trace_id` default to the ambient attribution execution key (the
|
|
114
|
+
persistence key during persisted runs) instead of a fresh random UUID per
|
|
115
|
+
event; the random fallback remains for events built outside any execution
|
|
116
|
+
scope. The `:tool_call` trace gains a nullable `tool_call_id` when the
|
|
117
|
+
invocation came from a provider batch (the tool-results capture entry
|
|
118
|
+
gains the same key under a later bullet in this release; composite
|
|
119
|
+
effects accept the extended shape with bounded value validation).
|
|
120
|
+
|
|
121
|
+
- Emit from the step-failure paths, closing the success-only observation
|
|
122
|
+
gap: both `handle_step_failure` and the unresolved-transition handler now
|
|
123
|
+
record a `:transition` trace with `outcome: :failed` plus bounded
|
|
124
|
+
classification (`error_class`, `error_family` from FailureRecord's
|
|
125
|
+
taxonomy, `retryable`) and emit a new `Smith::Events::StepFailed` event.
|
|
126
|
+
Raw error messages never ride either; an emission failure is logged and
|
|
127
|
+
can never mask the original step error. The marker key is `outcome`
|
|
128
|
+
because `result` is a reserved content key in the trace pipeline. An
|
|
129
|
+
unresolved transition with no `:fail` transition still re-raises without
|
|
130
|
+
emitting: that path was never treated as a step. The unresolved handler
|
|
131
|
+
runs outside any step context, so it seeds the run identity explicitly;
|
|
132
|
+
both failure paths stamp persisted-run events with the persistence key.
|
|
133
|
+
Emission is terminal-per-step: a step that retries internally and then
|
|
134
|
+
succeeds emits only its `StepCompleted`; intermediate step-body retry
|
|
135
|
+
attempts stay dark at the step layer (provider-level failures remain
|
|
136
|
+
visible as `:provider_call` failure traces). `StepFailed` handlers run
|
|
137
|
+
outside the step snapshot's interrupt-masked region (emission is staged
|
|
138
|
+
in the failure rescue and flushed after the mask closes), interruptible
|
|
139
|
+
exactly like `StepCompleted` handlers. A step body that surfaces Smith's
|
|
140
|
+
own `UnresolvedTransitionError` emits exactly one `StepFailed` under the
|
|
141
|
+
real step identity; the unresolved handler recognizes the already-emitted
|
|
142
|
+
error instead of emitting a second event under the requested (never
|
|
143
|
+
executed) name.
|
|
144
|
+
- Emit the long-advertised `:cost` trace: one per completed agent
|
|
145
|
+
invocation, whose value is the sum of that invocation's per-response
|
|
146
|
+
usage-entry costs. Summing per response is what tiered catalogs actually
|
|
147
|
+
bill; pricing the aggregate token totals as one call would resolve the
|
|
148
|
+
wrong tier for multi-response tool loops. Emitted only for fully metered,
|
|
149
|
+
fully priced invocations (a partially priced or partially metered
|
|
150
|
+
invocation emits nothing rather than presenting an incomplete figure);
|
|
151
|
+
gated by the existing `trace_cost` setting (the per-type gates live in
|
|
152
|
+
the built-in adapters; a custom adapter receives every type). `:cost`
|
|
153
|
+
traces are not a spend total: billed failed and partial attempts appear
|
|
154
|
+
only in usage entries. The same per-response sum now becomes
|
|
155
|
+
`agent_result.cost`, so budget settlement, result surfaces, recorded
|
|
156
|
+
entries, and the trace all agree on one invocation cost.
|
|
157
|
+
- The OpenTelemetry adapter now creates retroactive spans with real
|
|
158
|
+
durations (span start backdated by `:tool_call` seconds or
|
|
159
|
+
`:provider_call` milliseconds; instant spans otherwise), preserves
|
|
160
|
+
numeric attribute types instead of stringifying everything, and uses only
|
|
161
|
+
the documented opentelemetry-api surface (`Tracer#start_span` with
|
|
162
|
+
`start_timestamp`, `Span#finish` with `end_timestamp`).
|
|
163
|
+
|
|
164
|
+
- Add a `workflow` discriminator to the ambient attribution, every trace
|
|
165
|
+
payload, usage entries, and the `StepCompleted`/`StepFailed` events: the
|
|
166
|
+
emitting workflow's class name ("anonymous" when unnamed), so
|
|
167
|
+
nested-child graph facts are distinguishable from parent facts under the
|
|
168
|
+
shared root execution identity. Nil-omitted from serialized entries like
|
|
169
|
+
the other attribution members.
|
|
170
|
+
- Every provider attempt now emits its `:provider_call` trace: `outcome` is
|
|
171
|
+
`:success`, `:failure` (provider failure, fallback may continue), or
|
|
172
|
+
`:aborted` (a non-provider error that re-raises), so prefix-accounted
|
|
173
|
+
usage entries always have their attempt join target.
|
|
174
|
+
- The tool-results capture entry gains `tool_call_id` when the invocation
|
|
175
|
+
came from a provider batch (omitted otherwise, so direct-invocation and
|
|
176
|
+
pre-existing payloads keep their exact two-key shape); composite effects
|
|
177
|
+
accept the extended shape while still rejecting unknown keys.
|
|
178
|
+
- `StepFailed` handlers now run outside the step snapshot's
|
|
179
|
+
interrupt-masked region: emission is staged in the failure rescue and
|
|
180
|
+
flushed after the mask closes with explicitly seeded run identity, so a
|
|
181
|
+
slow host handler can no longer make the workflow thread unkillable and
|
|
182
|
+
handlers match `StepCompleted`'s interruptibility.
|
|
183
|
+
|
|
184
|
+
### Fixed
|
|
185
|
+
|
|
186
|
+
- A user-declared `:fail` transition no longer inherits the early order
|
|
187
|
+
position of the auto-generated placeholder created by `state :failed`:
|
|
188
|
+
redeclaring a generated transition takes a fresh declaration position, so
|
|
189
|
+
it can no longer shadow a same-origin primary transition at run time.
|
|
190
|
+
Genuine user redefinitions keep their original position, and subclasses
|
|
191
|
+
inherit the bookkeeping.
|
|
192
|
+
- Budget cost settlement now consumes the per-response priced sum instead
|
|
193
|
+
of pricing the invocation's aggregate token totals. Under linear pricing
|
|
194
|
+
the figures are identical; under tiered pricing the aggregate resolved
|
|
195
|
+
the wrong tier (or missed every tier and settled zero), so a
|
|
196
|
+
cost-budgeted workflow could keep spending after its real billed cost
|
|
197
|
+
exceeded the budget.
|
|
198
|
+
|
|
199
|
+
### Removed
|
|
200
|
+
|
|
201
|
+
- Remove the never-read `trace_retention` and `trace_tenant_isolation`
|
|
202
|
+
settings. Both were silent no-ops since introduction; reading or writing
|
|
203
|
+
them now raises, so a host relying on the illusion fails loudly instead
|
|
204
|
+
of silently.
|
|
205
|
+
|
|
206
|
+
### Changed
|
|
207
|
+
|
|
208
|
+
- Workflow step failure is now observable: subscribers to the events bus
|
|
209
|
+
receive `StepFailed` where previously failures emitted nothing (the
|
|
210
|
+
success-only scope is gone), and trace consumers see failed `:transition`
|
|
211
|
+
payloads distinguished by `outcome: :failed`.
|
|
212
|
+
- `Workflow::Composite::Effects` validates usage-entry keys as
|
|
213
|
+
required-plus-allowed instead of exact: entries from an older producer
|
|
214
|
+
(missing the optional attribution keys) stay valid, current entries with
|
|
215
|
+
attribution pass, and unknown keys still reject. The optional keys are
|
|
216
|
+
bounded values, not just bounded keys: `transition`, `branch_key`, and
|
|
217
|
+
`workflow` must be non-empty Strings up to 256 characters, `round` a
|
|
218
|
+
non-negative Integer, and `attempt_id` a UUID when present. Tool results
|
|
219
|
+
accept the extended capture shape: `tool_call_id`, when present, must be
|
|
220
|
+
a non-empty String up to 1024 characters (`tool` keeps its exact prior
|
|
221
|
+
validation).
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
- `Smith::Events` subscriptions now live in per-class buckets guarded by a
|
|
225
|
+
mutex: emit touches only the buckets for the event's ancestors instead of
|
|
226
|
+
scanning every subscription, dispatch order remains registration order,
|
|
227
|
+
and `is_a?` matching semantics are unchanged (instance-extended modules
|
|
228
|
+
dispatch through the singleton class; immediate values, which have no
|
|
229
|
+
singleton class and cannot be extended, dispatch through their class
|
|
230
|
+
ancestors). `Subscription#cancel` now
|
|
231
|
+
detaches from the registry, so cancelled subscriptions (including
|
|
232
|
+
`Events.within` scopes) no longer leak. Handlers run outside the registry
|
|
233
|
+
lock, so a handler may subscribe or cancel without deadlocking.
|
|
234
|
+
- `Smith::Trace::Memory#record` and `#clear!` are mutex-guarded and safe
|
|
235
|
+
under parallel fan-out branches.
|
|
236
|
+
|
|
7
237
|
## [0.8.0] - 2026-07-25
|
|
8
238
|
|
|
9
239
|
### Added
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -83,8 +83,8 @@ Smith.configure do |config|
|
|
|
83
83
|
tool_call: %i[tool duration]
|
|
84
84
|
}
|
|
85
85
|
config.trace_content = false
|
|
86
|
-
config.
|
|
87
|
-
config.
|
|
86
|
+
config.trace_provider_calls = true
|
|
87
|
+
config.trace_attribution = true
|
|
88
88
|
|
|
89
89
|
config.pricing = {
|
|
90
90
|
%w[openai gpt-4.1-nano] => {
|
|
@@ -115,11 +115,11 @@ observes a provider but finds only a model-only rate, it raises
|
|
|
115
115
|
| `trace_transitions` | Emit transition traces | Usually leave on |
|
|
116
116
|
| `trace_tool_calls` | Emit tool call traces | Usually leave on |
|
|
117
117
|
| `trace_token_usage` | Emit usage traces | Useful for budget visibility |
|
|
118
|
-
| `
|
|
118
|
+
| `trace_provider_calls` | Emit one timed `:provider_call` trace per provider attempt | Usually leave on |
|
|
119
|
+
| `trace_cost` | Emit one `:cost` trace per completed agent invocation | Emits only once pricing is configured |
|
|
119
120
|
| `trace_fields` | Allowlist structural trace fields | Use when you want tighter trace output |
|
|
120
121
|
| `trace_content` | Whether content appears in traces | Leave `false` first |
|
|
121
|
-
| `
|
|
122
|
-
| `trace_tenant_isolation` | Trace multi-tenant isolation flag | Enable in multi-tenant systems |
|
|
122
|
+
| `trace_attribution` | Merge ambient execution attribution into trace payloads | Usually leave on |
|
|
123
123
|
| `pricing` | Best-known model-call cost catalog | Add once you care about `total_cost` |
|
|
124
124
|
| `logger` | Smith's runtime logger | Usually the first setting to add |
|
|
125
125
|
| `persistence_adapter` | Adapter for durable workflow state | `:redis`, `:rails_cache`, `:active_record`, `:memory`, or a custom object |
|
|
@@ -5,9 +5,18 @@ module Smith
|
|
|
5
5
|
module CompletionUsageRecording
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
# Returns [invocation_cost, fully_priced]. The cost is the sum of the
|
|
9
|
+
# recorded entries' costs (nil when nothing was priced): per-response
|
|
10
|
+
# pricing is what tiered catalogs bill, so the invocation cost comes
|
|
11
|
+
# from this sum, never from pricing the aggregate token totals (which
|
|
12
|
+
# would resolve the wrong tier for multi-response tool loops).
|
|
13
|
+
# fully_priced is true only when every provider response carried usage
|
|
14
|
+
# and every usage priced; a partial sum is still returned (it is what
|
|
15
|
+
# was verifiably billed) but callers must not present it as the
|
|
16
|
+
# complete invocation cost.
|
|
17
|
+
def record_completion_usage(agent_class, completion, attempt_kind, model_reference, attempt_id: nil)
|
|
9
18
|
model_reference = coerce_model_reference(model_reference)
|
|
10
|
-
completion.provider_usages.
|
|
19
|
+
costs = completion.provider_usages.map do |usage|
|
|
11
20
|
result = Workflow::AgentResult.new(
|
|
12
21
|
content: nil,
|
|
13
22
|
input_tokens: usage.input_tokens,
|
|
@@ -17,8 +26,17 @@ module Smith
|
|
|
17
26
|
provider_used: model_reference.provider
|
|
18
27
|
)
|
|
19
28
|
compute_agent_cost(result)
|
|
20
|
-
record_usage(agent_class, result, attempt_kind, model_reference)
|
|
29
|
+
record_usage(agent_class, result, attempt_kind, model_reference, attempt_id:)
|
|
30
|
+
result.cost
|
|
21
31
|
end
|
|
32
|
+
summarize_invocation_costs(costs, completion)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def summarize_invocation_costs(costs, completion)
|
|
36
|
+
priced = costs.compact
|
|
37
|
+
invocation_cost = priced.empty? ? nil : priced.sum
|
|
38
|
+
fully_priced = completion.usage_complete && !costs.empty? && priced.length == costs.length
|
|
39
|
+
[invocation_cost, fully_priced]
|
|
22
40
|
end
|
|
23
41
|
end
|
|
24
42
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Agent
|
|
5
7
|
module InvocationPreparation
|
|
@@ -82,9 +84,31 @@ module Smith
|
|
|
82
84
|
else
|
|
83
85
|
message
|
|
84
86
|
end
|
|
87
|
+
attributes = provider_safe_message(attributes) if attributes.is_a?(Hash)
|
|
85
88
|
chat.add_message(attributes)
|
|
86
89
|
end
|
|
87
90
|
|
|
91
|
+
# A structured agent output recorded as a session message (StepCompletion#append_accepted_output)
|
|
92
|
+
# carries a Hash/Array content. RubyLLM's Message#normalize_content treats a Hash content as
|
|
93
|
+
# { text:, ...attachments } and opens each value as a file, so replaying a prior structured
|
|
94
|
+
# output to the next agent in a workflow session raises Errno::ENOENT. Serialize non-string
|
|
95
|
+
# content to JSON so the provider sees the prior output as text; the session store keeps the raw
|
|
96
|
+
# value (last_output stays structured), only this provider-facing copy is serialized. Genuine
|
|
97
|
+
# multimodal attachments are supplied through the provider's own with: mechanism, never as a bare
|
|
98
|
+
# Hash message content in a workflow session.
|
|
99
|
+
def provider_safe_message(attributes)
|
|
100
|
+
content = attributes[:content]
|
|
101
|
+
return attributes if content.nil? || content.is_a?(String)
|
|
102
|
+
|
|
103
|
+
attributes.merge(content: json_message_content(content))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def json_message_content(content)
|
|
107
|
+
JSON.generate(content)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
content.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
88
112
|
def message_role(message)
|
|
89
113
|
message_attribute(message, :role)&.to_sym
|
|
90
114
|
end
|
|
@@ -20,8 +20,10 @@ module Smith
|
|
|
20
20
|
|
|
21
21
|
def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
|
|
22
22
|
check_deadline!
|
|
23
|
-
|
|
24
|
-
snapshot_and_finalize(
|
|
23
|
+
attempt = complete_with_provider(agent_class, prepared_input, output_schema:)
|
|
24
|
+
snapshot_and_finalize(
|
|
25
|
+
agent_class, attempt.completion, attempt.model_reference, attempt_id: attempt.attempt_id
|
|
26
|
+
)
|
|
25
27
|
end
|
|
26
28
|
end
|
|
27
29
|
end
|
|
@@ -11,13 +11,19 @@ module Smith
|
|
|
11
11
|
attribute :completion, Types::Any.optional
|
|
12
12
|
attribute :model_reference, Types.Instance(ModelReference)
|
|
13
13
|
attribute :error, Types.Instance(StandardError).optional
|
|
14
|
+
# One attempt = one chat completion (including any provider tool loop).
|
|
15
|
+
# `attempt_id` joins the attempt's usage entries to its single measured
|
|
16
|
+
# duration; `duration_ms` is nil when the attempt failed before the
|
|
17
|
+
# completion call started.
|
|
18
|
+
attribute :attempt_id, Types::String.optional.default(nil)
|
|
19
|
+
attribute :duration_ms, Types::Integer.optional.default(nil)
|
|
14
20
|
|
|
15
|
-
def self.success(completion:, model_reference:)
|
|
16
|
-
new(completion:, model_reference:, error: nil)
|
|
21
|
+
def self.success(completion:, model_reference:, attempt_id: nil, duration_ms: nil)
|
|
22
|
+
new(completion:, model_reference:, error: nil, attempt_id:, duration_ms:)
|
|
17
23
|
end
|
|
18
24
|
|
|
19
|
-
def self.failure(error:, model_reference:)
|
|
20
|
-
new(completion: nil, model_reference:, error:)
|
|
25
|
+
def self.failure(error:, model_reference:, attempt_id: nil, duration_ms: nil)
|
|
26
|
+
new(completion: nil, model_reference:, error:, attempt_id:, duration_ms:)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
def success?
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# Monotonic timing and trace emission for one provider attempt: the
|
|
6
|
+
# attempt's single measured duration wraps the whole chat completion
|
|
7
|
+
# (including any provider-side tool loop). Per-network-round timing
|
|
8
|
+
# belongs to the host's RubyLLM notification subscriptions, not Smith.
|
|
9
|
+
module ProviderCallTiming
|
|
10
|
+
class Timer
|
|
11
|
+
def self.start
|
|
12
|
+
new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@stopped_at = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Idempotent: the first stop wins, so a rescue-path stop after a
|
|
21
|
+
# success-path stop cannot stretch the measurement.
|
|
22
|
+
def stop
|
|
23
|
+
@stopped_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def elapsed_ms
|
|
28
|
+
ending = @stopped_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
((ending - @started_at) * 1000).round
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# One `:provider_call` trace per attempt: success, provider failure, or
|
|
36
|
+
# aborted (a non-provider error that re-raises). Usage entries from the
|
|
37
|
+
# same attempt share its attempt_id; join there instead of summing
|
|
38
|
+
# durations across entries. duration_ms is present only when the timed
|
|
39
|
+
# provider call actually started: a failure before dispatch (model
|
|
40
|
+
# resolution, chat construction) emits its attempt without a duration.
|
|
41
|
+
def record_provider_call_trace(attempt, attempt_index, aborted: false)
|
|
42
|
+
Smith::Trace.record(
|
|
43
|
+
type: :provider_call,
|
|
44
|
+
data: {
|
|
45
|
+
model: attempt.model_reference.model_id,
|
|
46
|
+
provider: attempt.model_reference.provider,
|
|
47
|
+
duration_ms: attempt.duration_ms,
|
|
48
|
+
attempt_id: attempt.attempt_id,
|
|
49
|
+
attempt_index: attempt_index,
|
|
50
|
+
outcome: provider_call_outcome(attempt, aborted)
|
|
51
|
+
}.compact
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def provider_call_outcome(attempt, aborted)
|
|
56
|
+
return :aborted if aborted
|
|
57
|
+
return :success if attempt.success?
|
|
58
|
+
|
|
59
|
+
:failure
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Aborted (non-provider) attempts emit a :provider_call too, so the
|
|
63
|
+
# prefix-accounted usage entries stamped with this attempt_id always
|
|
64
|
+
# have their join target; the error then propagates unchanged.
|
|
65
|
+
def failed_provider_attempt(error, observed_reference, attempt_id, timer, attempt_index)
|
|
66
|
+
attempt = ProviderAttempt.failure(
|
|
67
|
+
error: error, model_reference: observed_reference, attempt_id:, duration_ms: timer&.elapsed_ms
|
|
68
|
+
)
|
|
69
|
+
record_provider_call_trace(attempt, attempt_index, aborted: !provider_failure?(error))
|
|
70
|
+
attempt
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "provider_failure_handling"
|
|
4
4
|
require_relative "invocation_preparation"
|
|
5
5
|
require_relative "provider_attempt"
|
|
6
|
+
require_relative "provider_call_timing"
|
|
6
7
|
require_relative "provider_candidate_sequence"
|
|
7
8
|
|
|
8
9
|
module Smith
|
|
@@ -10,6 +11,7 @@ module Smith
|
|
|
10
11
|
module ProviderCompletion
|
|
11
12
|
include ProviderFailureHandling
|
|
12
13
|
include InvocationPreparation
|
|
14
|
+
include ProviderCallTiming
|
|
13
15
|
|
|
14
16
|
private
|
|
15
17
|
|
|
@@ -17,13 +19,14 @@ module Smith
|
|
|
17
19
|
candidates = ProviderCandidateSequence.new(build_model_chain(agent_class))
|
|
18
20
|
candidates.each do |model_reference, index|
|
|
19
21
|
check_deadline! if index.positive?
|
|
20
|
-
attempt = attempt_model(agent_class, prepared_input, model_reference, output_schema:)
|
|
21
|
-
return
|
|
22
|
+
attempt = attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index: index)
|
|
23
|
+
return attempt if attempt.success?
|
|
22
24
|
|
|
23
25
|
candidates.suppress(account_failed_provider(attempt, model_reference))
|
|
24
26
|
handle_provider_failure!(
|
|
25
27
|
attempt.error, attempt.model_reference, agent_class,
|
|
26
|
-
fallback_available: candidates.fallback_available
|
|
28
|
+
fallback_available: candidates.fallback_available?,
|
|
29
|
+
attempt_id: attempt.attempt_id
|
|
27
30
|
)
|
|
28
31
|
end
|
|
29
32
|
|
|
@@ -79,20 +82,35 @@ module Smith
|
|
|
79
82
|
raise Smith::AgentError, "invalid model block result for #{agent_class}: #{e.message}"
|
|
80
83
|
end
|
|
81
84
|
|
|
82
|
-
|
|
85
|
+
# rubocop:disable Metrics/AbcSize -- one provider attempt is a single
|
|
86
|
+
# cohesive lifecycle (identity, prepared chat, observed model, timed
|
|
87
|
+
# completion, prefix accounting on failure); splitting it would scatter
|
|
88
|
+
# the rescue-path accounting away from what it accounts for.
|
|
89
|
+
def attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index:)
|
|
90
|
+
attempt_id = SecureRandom.uuid
|
|
83
91
|
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
|
|
84
92
|
message_count = chat_message_count(chat)
|
|
85
93
|
observed_reference = observed_model_reference(chat, fallback: model_reference)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
94
|
+
timer = ProviderCallTiming::Timer.start
|
|
95
|
+
response = chat.complete
|
|
96
|
+
timer.stop
|
|
97
|
+
completion = Completion.from_messages(response: response, messages: new_chat_messages(chat, message_count))
|
|
98
|
+
|
|
99
|
+
attempt = ProviderAttempt.success(
|
|
100
|
+
completion:, model_reference: observed_reference, attempt_id:, duration_ms: timer.elapsed_ms
|
|
101
|
+
)
|
|
102
|
+
record_provider_call_trace(attempt, attempt_index)
|
|
103
|
+
attempt
|
|
89
104
|
rescue StandardError => e
|
|
105
|
+
timer&.stop
|
|
90
106
|
observed_reference ||= observed_model_reference(chat, fallback: model_reference)
|
|
91
|
-
account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count))
|
|
107
|
+
account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count), attempt_id:)
|
|
108
|
+
attempt = failed_provider_attempt(e, observed_reference, attempt_id, timer, attempt_index)
|
|
92
109
|
raise unless provider_failure?(e)
|
|
93
110
|
|
|
94
|
-
|
|
111
|
+
attempt
|
|
95
112
|
end
|
|
113
|
+
# rubocop:enable Metrics/AbcSize
|
|
96
114
|
|
|
97
115
|
def observed_model_reference(chat, fallback:)
|
|
98
116
|
model = observable_model(chat)
|
|
@@ -21,8 +21,8 @@ module Smith
|
|
|
21
21
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
|
-
def handle_provider_failure!(error, model_reference, agent_class, fallback_available:)
|
|
25
|
-
account_failed_attempt(error, model_reference, agent_class)
|
|
24
|
+
def handle_provider_failure!(error, model_reference, agent_class, fallback_available:, attempt_id: nil)
|
|
25
|
+
account_failed_attempt(error, model_reference, agent_class, attempt_id:)
|
|
26
26
|
if completed_tool_calls?
|
|
27
27
|
raise Smith::ToolOutcomeUncertain.new(
|
|
28
28
|
"provider failed after tool execution began; retry or fallback could replay an uncertain outcome"
|
|
@@ -5,15 +5,22 @@ module Smith
|
|
|
5
5
|
module UsageEntryRecording
|
|
6
6
|
private
|
|
7
7
|
|
|
8
|
-
def record_usage(agent_class, agent_result, attempt_kind, model_reference)
|
|
8
|
+
def record_usage(agent_class, agent_result, attempt_kind, model_reference, attempt_id: nil)
|
|
9
9
|
return unless agent_result.usage_known?
|
|
10
10
|
|
|
11
11
|
model_reference = coerce_model_reference(model_reference)
|
|
12
|
-
entry = build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
|
|
12
|
+
entry = build_usage_entry(agent_class, agent_result, attempt_kind, model_reference, attempt_id:)
|
|
13
13
|
accumulate_usage(agent_result, entry)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# Attribution (transition, branch key, optimizer round) is read from the
|
|
17
|
+
# ambient context of the recording thread, which is the thread that ran
|
|
18
|
+
# the provider call: a fan-out branch records under its own overlay.
|
|
19
|
+
# transition and branch_key are recorded as Symbols even when a host
|
|
20
|
+
# seeded Strings through Attribution.with, because from_h symbolizes
|
|
21
|
+
# them on restore: recording the same way keeps a restored entry equal
|
|
22
|
+
# to the recorded one.
|
|
23
|
+
def build_usage_entry(agent_class, agent_result, attempt_kind, model_reference, attempt_id: nil)
|
|
17
24
|
Workflow::UsageEntry.new(
|
|
18
25
|
usage_id: SecureRandom.uuid,
|
|
19
26
|
agent_name: agent_class.register_as,
|
|
@@ -23,10 +30,26 @@ module Smith
|
|
|
23
30
|
output_tokens: agent_result.output_tokens,
|
|
24
31
|
cost: agent_result.cost,
|
|
25
32
|
attempt_kind: attempt_kind,
|
|
26
|
-
recorded_at: Time.now.utc.iso8601
|
|
33
|
+
recorded_at: Time.now.utc.iso8601(6),
|
|
34
|
+
attempt_id: attempt_id,
|
|
35
|
+
**ambient_attribution_fields
|
|
27
36
|
)
|
|
28
37
|
end
|
|
29
38
|
|
|
39
|
+
def ambient_attribution_fields
|
|
40
|
+
attribution = Smith::Attribution.ambient
|
|
41
|
+
{
|
|
42
|
+
transition: symbolized_attribution(attribution.transition),
|
|
43
|
+
branch_key: symbolized_attribution(attribution.branch_key),
|
|
44
|
+
round: attribution.round,
|
|
45
|
+
workflow: attribution.workflow
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def symbolized_attribution(value)
|
|
50
|
+
value.is_a?(String) ? value.to_sym : value
|
|
51
|
+
end
|
|
52
|
+
|
|
30
53
|
def accumulate_usage(agent_result, entry)
|
|
31
54
|
@usage_mutex.synchronize do
|
|
32
55
|
@total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# Trace emission for a completed agent invocation's usage facts, kept
|
|
6
|
+
# apart from the accounting itself (UsageTracking) so recording rows and
|
|
7
|
+
# emitting observability stay separate concerns.
|
|
8
|
+
module UsageTraces
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def emit_token_usage(agent_result)
|
|
12
|
+
return unless agent_result.usage_known?
|
|
13
|
+
|
|
14
|
+
Smith::Trace.record(
|
|
15
|
+
type: :token_usage,
|
|
16
|
+
data: {
|
|
17
|
+
input_tokens: agent_result.input_tokens,
|
|
18
|
+
output_tokens: agent_result.output_tokens,
|
|
19
|
+
model: agent_result.model_used,
|
|
20
|
+
provider: agent_result.provider_used
|
|
21
|
+
}.compact
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One :cost trace per completed agent invocation. The cost is the sum
|
|
26
|
+
# of the invocation's per-response entry costs (what tiered catalogs
|
|
27
|
+
# actually bill), never the aggregate token totals priced as one call.
|
|
28
|
+
# Token counts remain the invocation aggregates. Billed failed and
|
|
29
|
+
# partial attempts appear only in usage entries, so summing :cost
|
|
30
|
+
# traces is not a spend total. Unpriced usage emits nothing, and the
|
|
31
|
+
# caller gates out partially metered or partially priced invocations
|
|
32
|
+
# so an incomplete figure is never presented as the invocation cost.
|
|
33
|
+
def emit_cost_trace(agent_result, invocation_cost)
|
|
34
|
+
return if invocation_cost.nil?
|
|
35
|
+
|
|
36
|
+
Smith::Trace.record(
|
|
37
|
+
type: :cost,
|
|
38
|
+
data: {
|
|
39
|
+
cost: invocation_cost,
|
|
40
|
+
model: agent_result.model_used,
|
|
41
|
+
provider: agent_result.provider_used,
|
|
42
|
+
input_tokens: agent_result.input_tokens,
|
|
43
|
+
output_tokens: agent_result.output_tokens
|
|
44
|
+
}.compact
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|