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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +230 -0
  3. data/docs/CONFIGURATION.md +5 -5
  4. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  5. data/lib/smith/agent/invocation_preparation.rb +24 -0
  6. data/lib/smith/agent/lifecycle.rb +4 -2
  7. data/lib/smith/agent/provider_attempt.rb +10 -4
  8. data/lib/smith/agent/provider_call_timing.rb +74 -0
  9. data/lib/smith/agent/provider_completion.rb +27 -9
  10. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  11. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  12. data/lib/smith/agent/usage_traces.rb +49 -0
  13. data/lib/smith/agent/usage_tracking.rb +23 -20
  14. data/lib/smith/attribution.rb +115 -0
  15. data/lib/smith/event.rb +10 -2
  16. data/lib/smith/events/bus.rb +63 -5
  17. data/lib/smith/events/step_completed.rb +3 -0
  18. data/lib/smith/events/step_failed.rb +25 -0
  19. data/lib/smith/events/subscription.rb +8 -0
  20. data/lib/smith/tool/capture.rb +9 -1
  21. data/lib/smith/tool.rb +10 -1
  22. data/lib/smith/trace/logger.rb +1 -0
  23. data/lib/smith/trace/memory.rb +40 -4
  24. data/lib/smith/trace/open_telemetry.rb +47 -3
  25. data/lib/smith/trace.rb +24 -5
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/composite/effects.rb +64 -33
  28. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  29. data/lib/smith/workflow/deterministic_step.rb +14 -1
  30. data/lib/smith/workflow/dsl.rb +25 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  32. data/lib/smith/workflow/event_integration.rb +48 -1
  33. data/lib/smith/workflow/execution.rb +8 -0
  34. data/lib/smith/workflow/fanout_execution.rb +1 -1
  35. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  36. data/lib/smith/workflow/parallel_execution.rb +6 -0
  37. data/lib/smith/workflow/persistence.rb +33 -1
  38. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  39. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  40. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  41. data/lib/smith/workflow/step_completion.rb +9 -0
  42. data/lib/smith/workflow/step_context.rb +46 -0
  43. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  44. data/lib/smith/workflow/usage_entry.rb +32 -4
  45. data/lib/smith/workflow.rb +26 -1
  46. data/lib/smith.rb +7 -2
  47. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1fe948e343baa2573448f7a95a964729e96a71b0b2e6ef16226694400ba0fb97
4
- data.tar.gz: 8f3d35e4ee6998ca41a4be0e19dcce0b5e843b44c0f28167aff3d5326d1be573
3
+ metadata.gz: 06665b52b660baf1f846ebd104caec677044595bd4aaf71ecf6ac3120e18d478
4
+ data.tar.gz: '09dd67e047e0f2a3dc6afd0f6b320ab7d5b7f2f23460ddae9576860e7b2e1329'
5
5
  SHA512:
6
- metadata.gz: 85bc7336ef0cb273979c4c7df1a4c0c724eb74cb34c4b49c1b23768e9454c700a1f33827ac9a1b33ff9680efd55b302b282db0fc553c5e20c52b4d13970044fa
7
- data.tar.gz: afe1281c6ef7967292c1c43b7370ca5ab3795abcc98722aa8e10c03266347dcd2411d9c44878f1c0d4aa41df5f88ffd636d46084ff996f2008f60baa650ce5fc
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
@@ -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.trace_retention = 86_400
87
- config.trace_tenant_isolation = false
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
- | `trace_cost` | Emit cost traces | Useful once pricing is configured |
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
- | `trace_retention` | Trace retention policy hook | Useful when traces leave memory |
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
- def record_completion_usage(agent_class, completion, attempt_kind, model_reference)
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.each do |usage|
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
- completion, model_used = complete_with_provider(agent_class, prepared_input, output_schema:)
24
- snapshot_and_finalize(agent_class, completion, model_used)
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 [attempt.completion, attempt.model_reference] if attempt.success?
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
- def attempt_model(agent_class, prepared_input, model_reference, output_schema:)
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
- completion = Completion.from_messages(response: chat.complete, messages: new_chat_messages(chat, message_count))
87
-
88
- ProviderAttempt.success(completion:, model_reference: observed_reference)
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
- ProviderAttempt.failure(error: e, model_reference: observed_reference)
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
- def build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
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