smith-agents 0.8.0 → 0.11.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +433 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +26 -6
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/completion_usage_recording.rb +21 -3
  8. data/lib/smith/agent/fallback_configuration.rb +26 -3
  9. data/lib/smith/agent/invocation_preparation.rb +24 -0
  10. data/lib/smith/agent/lifecycle.rb +4 -2
  11. data/lib/smith/agent/provider_attempt.rb +17 -4
  12. data/lib/smith/agent/provider_call_timing.rb +129 -0
  13. data/lib/smith/agent/provider_completion.rb +26 -8
  14. data/lib/smith/agent/provider_failure_handling.rb +2 -2
  15. data/lib/smith/agent/provider_usage.rb +6 -0
  16. data/lib/smith/agent/usage_entry_recording.rb +27 -4
  17. data/lib/smith/agent/usage_traces.rb +49 -0
  18. data/lib/smith/agent/usage_tracking.rb +26 -20
  19. data/lib/smith/agent.rb +9 -1
  20. data/lib/smith/attribution.rb +115 -0
  21. data/lib/smith/budget/amount_representation.rb +20 -0
  22. data/lib/smith/budget/ledger.rb +6 -2
  23. data/lib/smith/context/state_injection.rb +12 -2
  24. data/lib/smith/diagnostic_text.rb +10 -1
  25. data/lib/smith/doctor/checks/durability.rb +16 -0
  26. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  27. data/lib/smith/errors.rb +55 -4
  28. data/lib/smith/event.rb +10 -2
  29. data/lib/smith/events/bus.rb +63 -5
  30. data/lib/smith/events/step_completed.rb +3 -0
  31. data/lib/smith/events/step_failed.rb +25 -0
  32. data/lib/smith/events/subscription.rb +8 -0
  33. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  34. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  35. data/lib/smith/persistence_adapters.rb +10 -0
  36. data/lib/smith/provider_permanent_failure.rb +44 -0
  37. data/lib/smith/tool/capture.rb +9 -1
  38. data/lib/smith/tool.rb +10 -1
  39. data/lib/smith/trace/logger.rb +1 -0
  40. data/lib/smith/trace/memory.rb +40 -4
  41. data/lib/smith/trace/open_telemetry.rb +47 -3
  42. data/lib/smith/trace.rb +24 -5
  43. data/lib/smith/version.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +3 -3
  45. data/lib/smith/workflow/composite/effects.rb +64 -33
  46. data/lib/smith/workflow/deterministic_execution.rb +3 -2
  47. data/lib/smith/workflow/deterministic_step.rb +15 -2
  48. data/lib/smith/workflow/dsl.rb +40 -6
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
  51. data/lib/smith/workflow/event_integration.rb +48 -1
  52. data/lib/smith/workflow/execution.rb +10 -0
  53. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  54. data/lib/smith/workflow/failure_record.rb +6 -11
  55. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  56. data/lib/smith/workflow/fanout_execution.rb +1 -1
  57. data/lib/smith/workflow/guardrail_integration.rb +27 -0
  58. data/lib/smith/workflow/optimization_state.rb +2 -2
  59. data/lib/smith/workflow/parallel_execution.rb +6 -0
  60. data/lib/smith/workflow/persistence.rb +55 -6
  61. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  62. data/lib/smith/workflow/retry_execution.rb +4 -0
  63. data/lib/smith/workflow/run_result.rb +10 -0
  64. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  65. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  66. data/lib/smith/workflow/step_completion.rb +13 -0
  67. data/lib/smith/workflow/step_context.rb +46 -0
  68. data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
  69. data/lib/smith/workflow/usage_entry.rb +32 -4
  70. data/lib/smith/workflow.rb +30 -3
  71. data/lib/smith.rb +9 -4
  72. metadata +5 -1
@@ -33,6 +33,16 @@ module Smith
33
33
  steps.reverse.map { |step| step[:error] }.compact.first
34
34
  end
35
35
 
36
+ # Every verdict an optimize step's loops recorded, in step, attempt and
37
+ # round order, each a frozen record naming its step's transition. Like
38
+ # `steps`, they belong to the run that executed the step: a terminal
39
+ # restore has none.
40
+ def evaluations
41
+ steps.flat_map do |step|
42
+ (step[:evaluations] || []).map { |evaluation| { transition: step[:transition], **evaluation }.freeze }
43
+ end
44
+ end
45
+
36
46
  def failed_transition
37
47
  failure_detail&.fetch(:transition)
38
48
  end
@@ -40,7 +40,15 @@ module Smith
40
40
  end
41
41
 
42
42
  def capture_composite_branch(&block)
43
- output = within_raw_step_context { with_scoped_artifacts(&block) }
43
+ output = within_raw_step_context do
44
+ # Same ambient attribution the in-process step path installs, so
45
+ # durably executed branches tag usage and traces identically
46
+ # (execution key from the restored persistence key, the split
47
+ # step's transition, then the branch overlay downstream).
48
+ # Restoration is owned by within_raw_step_context's snapshot.
49
+ install_step_attribution(@split_step_transition) if @split_step_transition
50
+ with_scoped_artifacts(&block)
51
+ end
44
52
  [output, nil]
45
53
  rescue StandardError => e
46
54
  [nil, e]
@@ -58,6 +58,7 @@ module Smith
58
58
 
59
59
  def detach_split_step_results!
60
60
  @last_output = snapshot_value(@last_output)
61
+ @last_agent_execution = snapshot_value(@last_agent_execution)
61
62
  @last_failed_step = snapshot_value(@last_failed_step)
62
63
  @last_prepared_input = snapshot_value(@last_prepared_input)
63
64
  end
@@ -7,6 +7,16 @@ module Smith
7
7
 
8
8
  def complete_step(transition, output)
9
9
  step = { transition: transition.name, from: transition.from, to: transition.to, output: output }
10
+ # Fold in the serial agent attribution captured for this step (if any),
11
+ # then clear the transient carrier. record_step_snapshot reads these off
12
+ # the returned step to update the durable @last_agent_execution.
13
+ if @pending_agent_execution
14
+ step[:model] = @pending_agent_execution[:model]
15
+ step[:provider] = @pending_agent_execution[:provider]
16
+ end
17
+
18
+ @pending_agent_execution = nil
19
+ fold_pending_evaluations(step)
10
20
  result = SplitStepPersistence
11
21
  .instance_method(:prepare_split_step_execution_result)
12
22
  .bind_call(self, step)
@@ -14,6 +24,9 @@ module Smith
14
24
  SplitStepPersistence
15
25
  .instance_method(:commit_split_step_execution_result!)
16
26
  .bind_call(self, result)
27
+ # Cleared only once the record is committed: a completion that fails
28
+ # before this folds the same verdicts into its failure record.
29
+ @pending_evaluations = nil
17
30
  @state = transition.to
18
31
  @next_transition_name = @router_next_transition || transition.success_transition
19
32
  @router_next_transition = nil
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../attribution"
3
4
  require_relative "thread_context_snapshot"
4
5
 
5
6
  module Smith
@@ -10,6 +11,7 @@ module Smith
10
11
  def with_step_context(transition, &block)
11
12
  ThreadContextSnapshot.new.around do
12
13
  setup_step_context
14
+ install_step_attribution(transition)
13
15
  Thread.handle_interrupt(Object => :immediate, &block)
14
16
  rescue StandardError => e
15
17
  @outcome = nil
@@ -17,6 +19,32 @@ module Smith
17
19
  ensure
18
20
  teardown_step_context
19
21
  end
22
+ ensure
23
+ flush_step_failure_emission
24
+ end
25
+
26
+ # Runs after the snapshot's interrupt mask has closed, so host
27
+ # StepFailed handlers execute interruptible, exactly as StepCompleted
28
+ # handlers do inside the step. The step context is gone by now, so the
29
+ # run identity and workflow label are seeded explicitly; the failed
30
+ # step's own transition facts travel in the staged step hash. Flushes
31
+ # on every exit of with_step_context, including the re-raise paths.
32
+ # The read-clear-mark triple is masked so an async interrupt cannot
33
+ # strand a staged failure between read and clear; the marker lets the
34
+ # unresolved-transition handler recognize an error this path already
35
+ # emitted. Only the emission itself stays interruptible.
36
+ def flush_step_failure_emission
37
+ step = nil
38
+ Thread.handle_interrupt(Object => :never) do
39
+ step = @pending_step_failure
40
+ @pending_step_failure = nil
41
+ @emitted_step_failure_error = step[:error] if step
42
+ end
43
+ return unless step
44
+
45
+ Attribution.with(execution_key: @persistence_key, workflow: self.class.name || "anonymous") do
46
+ emit_step_failed(step)
47
+ end
20
48
  end
21
49
 
22
50
  def within_raw_step_context(&block)
@@ -34,6 +62,24 @@ module Smith
34
62
  Tool.current_tool_result_collector = tool_result_collector
35
63
  end
36
64
 
65
+ # The ambient attribution for this step. Restoration is owned by the
66
+ # surrounding ThreadContextSnapshot (the attribution thread key is one
67
+ # of its THREAD_KEYS), so installation is a plain assignment. A nil
68
+ # persistence key preserves any host-seeded outer execution_key, and
69
+ # `workflow` is always a string ("anonymous" when the class has no
70
+ # name) so a nested child always overrides the parent's label. The
71
+ # per-step facts (transition, from, to) are replaced verbatim, nil
72
+ # included: a nested child's `from: nil` transition must not inherit
73
+ # the parent step's `from` through the nil-ignoring merge. branch_key
74
+ # and round inherit deliberately (a child genuinely runs within them).
75
+ def install_step_attribution(transition)
76
+ Attribution.install(
77
+ Attribution.ambient
78
+ .merge(execution_key: @persistence_key, workflow: self.class.name || "anonymous")
79
+ .override(transition: transition.name, from: transition.from, to: transition.to)
80
+ )
81
+ end
82
+
37
83
  def teardown_step_context
38
84
  Tool.current_guardrails = nil
39
85
  Tool.current_deadline = nil
@@ -22,6 +22,7 @@ module Smith
22
22
  current_invocation
23
23
  ].freeze
24
24
  THREAD_KEYS = %i[
25
+ smith_attribution
25
26
  smith_call_deadline
26
27
  smith_call_ledger
27
28
  smith_failed_agent_results
@@ -2,9 +2,24 @@
2
2
 
3
3
  module Smith
4
4
  class Workflow
5
- # One row per agent provider call. `usage_id` is a UUID generated at
6
- # recording time and stable across persist/restore so hosts can use it as an
7
- # idempotency anchor.
5
+ # One row per provider RESPONSE within an agent provider attempt.
6
+ # `usage_id` is a UUID generated at recording time and stable across
7
+ # persist/restore so hosts can use it as an idempotency anchor.
8
+ #
9
+ # `transition`, `branch_key`, `round`, and `workflow` come from the
10
+ # ambient Smith::Attribution at recording time, so hosts can attribute
11
+ # usage to the exact step, fan-out branch, optimizer round, and (for
12
+ # nested workflows) the exact graph that spent it.
13
+ # `attempt_id` is shared by every entry the same provider attempt
14
+ # produced (an attempt with an N-round tool loop records N entries):
15
+ # join on it against the `:provider_call` trace event for the attempt's
16
+ # single measured duration; never sum a duration across entries. Every
17
+ # attempt emits its `:provider_call` (an attempt aborted by a
18
+ # non-provider error emits with `outcome: :aborted` before re-raising),
19
+ # so prefix-accounted entries always have their join target.
20
+ # All optional members are nil on entries restored from checkpoints written by
21
+ # earlier Smith versions, and nil attribution members are omitted from
22
+ # serialization so those documents re-serialize byte-identically.
8
23
  # rubocop:disable Style/RedundantStructKeywordInit
9
24
  UsageEntry = Struct.new(
10
25
  :usage_id,
@@ -16,6 +31,11 @@ module Smith
16
31
  :cost,
17
32
  :attempt_kind,
18
33
  :recorded_at,
34
+ :transition,
35
+ :branch_key,
36
+ :round,
37
+ :attempt_id,
38
+ :workflow,
19
39
  keyword_init: true
20
40
  ) do
21
41
  def initialize(**attributes)
@@ -26,10 +46,18 @@ module Smith
26
46
  freeze
27
47
  end
28
48
 
49
+ # Serialization is additive only when present: nil attribution members
50
+ # are omitted so an entry restored from a pre-attribution checkpoint
51
+ # re-serializes byte-identically. Hosts that digest whole persisted
52
+ # documents (exact-mutation proofs) depend on this stability.
53
+ def to_h
54
+ super.reject { |key, value| value.nil? && %i[transition branch_key round attempt_id workflow].include?(key) }
55
+ end
56
+
29
57
  def self.from_h(hash)
30
58
  sym = hash.transform_keys(&:to_sym)
31
59
  filtered = sym.slice(*members)
32
- %i[agent_name provider attempt_kind].each do |attribute|
60
+ %i[agent_name provider attempt_kind transition branch_key].each do |attribute|
33
61
  filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
34
62
  end
35
63
  new(**filtered)
@@ -44,7 +44,7 @@ module Smith
44
44
  @step_count = 0
45
45
  @next_transition_name = nil
46
46
  @ledger = ledger || build_ledger
47
- @created_at = created_at || Time.now.utc.iso8601
47
+ @created_at = created_at || Time.now.utc.iso8601(6)
48
48
  @updated_at = @created_at
49
49
  @total_cost = 0.0
50
50
  @total_tokens = 0
@@ -58,6 +58,16 @@ module Smith
58
58
  @usage_entries = []
59
59
  @usage_mutex = Mutex.new
60
60
  @last_output = nil
61
+ # Durable attribution of the most recent serial `execute :agent` step:
62
+ # { model:, provider: } of the model that actually served it (post
63
+ # fallback resolution), exposed to the following deterministic step as
64
+ # `last_agent_model` / `last_agent_provider`. `@pending_agent_execution`
65
+ # is the transient per-step carrier from `execute_serial_step` to
66
+ # `complete_step`; it is never persisted. `@pending_evaluations` is the
67
+ # optimizer's, carrying a loop's verdicts into its step's record.
68
+ @last_agent_execution = nil
69
+ @pending_agent_execution = nil
70
+ @pending_evaluations = nil
61
71
  @last_failed_step = nil
62
72
  # Optimistic-locking version. Incremented on each persist!; restored
63
73
  # from the persisted payload. Adapters that support store_versioned
@@ -112,7 +122,7 @@ module Smith
112
122
  execute_step(transition)
113
123
  end
114
124
  @step_count += 1
115
- @updated_at = Time.now.utc.iso8601
125
+ @updated_at = Time.now.utc.iso8601(6)
116
126
  record_step_snapshot(step_result)
117
127
  step_result
118
128
  rescue UnresolvedTransitionError => e
@@ -157,6 +167,18 @@ module Smith
157
167
  end
158
168
  end
159
169
 
170
+ # Public, read-only view of the per-provider-call usage ledger, so hosts
171
+ # can diff usage across a step boundary without paying a full to_state
172
+ # serialization. The entries themselves are frozen; the returned array is
173
+ # a frozen copy taken under the recording mutex. Tolerates allocated but
174
+ # unrestored instances (no mutex yet), which have no entries.
175
+ def usage_entries
176
+ mutex = @usage_mutex
177
+ return [].freeze unless mutex
178
+
179
+ mutex.synchronize { @usage_entries.dup.freeze }
180
+ end
181
+
160
182
  private
161
183
 
162
184
  def initial_persist_auto_seed
@@ -189,6 +211,10 @@ module Smith
189
211
  # matches `RunResult#output`'s `.compact.first` semantics).
190
212
  @last_failed_step = nil
191
213
  @last_output = step_result[:output] if step_result.key?(:output) && !step_result[:output].nil?
214
+ # Capture serial agent attribution only on steps that carried it (an
215
+ # `execute :agent` step); deterministic steps have no :model key, so
216
+ # the last agent execution persists across intervening compute steps.
217
+ @last_agent_execution = { model: step_result[:model], provider: step_result[:provider] } if step_result.key?(:model)
192
218
  end
193
219
  end
194
220
 
@@ -340,7 +366,8 @@ module Smith
340
366
  # Same rule applies to nested-workflow rollup (see
341
367
  # `nested_execution.rb`).
342
368
  def snapshot_usage_entries
343
- @usage_entries.map { |entry| Workflow::UsageEntry.from_h(snapshot_value(entry.to_h)) }
369
+ entries = @usage_mutex.synchronize { @usage_entries.dup }
370
+ entries.map { |entry| Workflow::UsageEntry.from_h(snapshot_value(entry.to_h)) }
344
371
  end
345
372
 
346
373
  def tool_result_collector
data/lib/smith.rb CHANGED
@@ -40,11 +40,15 @@ module Smith
40
40
  setting :trace_transitions, default: true
41
41
  setting :trace_tool_calls, default: true
42
42
  setting :trace_token_usage, default: true
43
+ setting :trace_provider_calls, default: true
43
44
  setting :trace_cost, default: true
44
45
  setting :trace_fields
45
46
  setting :trace_content, default: false
46
- setting :trace_retention
47
- setting :trace_tenant_isolation, default: false
47
+ # Merge the ambient Smith::Attribution fields (execution_key, transition,
48
+ # branch_key, round, ...) into every trace payload. Attribution keys are
49
+ # identifiers, not content; they still pass through any configured
50
+ # trace_fields allowlist, and caller-supplied keys win on conflict.
51
+ setting :trace_attribution, default: true
48
52
 
49
53
  # Pricing (§4.5 — model-call cost computation)
50
54
  setting :pricing, default: nil
@@ -55,8 +59,8 @@ module Smith
55
59
 
56
60
  # Persistence TTL in Integer seconds. nil (default) means workflows
57
61
  # persist indefinitely. Adapters that natively support TTL (Redis,
58
- # CacheStore, Memory) pass this through; ActiveRecordStore TTL is
59
- # deferred (would need an `expires_at` column + sweeper).
62
+ # CacheStore, Memory) pass this through; ActiveRecordStore has no expiry
63
+ # column and refuses a non-nil TTL.
60
64
  # Per-workflow `Workflow.persistence_ttl 1.day.to_i` DSL overrides this.
61
65
  setting :persistence_ttl, default: nil
62
66
 
@@ -187,6 +191,7 @@ require_relative "smith/events"
187
191
  require_relative "smith/events/subscription"
188
192
  require_relative "smith/events/bus"
189
193
  require_relative "smith/events/step_completed"
194
+ require_relative "smith/events/step_failed"
190
195
 
191
196
  # Budget (depends on Errors)
192
197
  require_relative "smith/budget"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -162,6 +162,7 @@ files:
162
162
  - lib/smith/agent/lifecycle.rb
163
163
  - lib/smith/agent/model_reference.rb
164
164
  - lib/smith/agent/provider_attempt.rb
165
+ - lib/smith/agent/provider_call_timing.rb
165
166
  - lib/smith/agent/provider_candidate_sequence.rb
166
167
  - lib/smith/agent/provider_completion.rb
167
168
  - lib/smith/agent/provider_failure_handling.rb
@@ -173,11 +174,13 @@ files:
173
174
  - lib/smith/agent/registry_binding.rb
174
175
  - lib/smith/agent/reserved_input_bridge.rb
175
176
  - lib/smith/agent/usage_entry_recording.rb
177
+ - lib/smith/agent/usage_traces.rb
176
178
  - lib/smith/agent/usage_tracking.rb
177
179
  - lib/smith/artifacts.rb
178
180
  - lib/smith/artifacts/file.rb
179
181
  - lib/smith/artifacts/memory.rb
180
182
  - lib/smith/artifacts/scoped_store.rb
183
+ - lib/smith/attribution.rb
181
184
  - lib/smith/budget.rb
182
185
  - lib/smith/budget/amount_contract.rb
183
186
  - lib/smith/budget/amount_representation.rb
@@ -218,6 +221,7 @@ files:
218
221
  - lib/smith/events/.keep
219
222
  - lib/smith/events/bus.rb
220
223
  - lib/smith/events/step_completed.rb
224
+ - lib/smith/events/step_failed.rb
221
225
  - lib/smith/events/subscription.rb
222
226
  - lib/smith/exponential_backoff.rb
223
227
  - lib/smith/guardrails.rb