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
@@ -1,9 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostic_text"
3
4
  require_relative "error"
4
5
 
5
6
  module Smith
6
7
  class ProviderPermanentFailure < Error
8
+ DETAIL_NAMES = %i[provider model_id source_error_class].freeze
9
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
10
+ MAX_DETAIL_BYTES = 512
11
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
12
+
7
13
  attr_reader :provider, :model_id, :source_error_class
8
14
 
9
15
  def initialize(message, provider:, model_id:, source_error_class:)
@@ -12,5 +18,43 @@ module Smith
12
18
  @source_error_class = source_error_class.to_s.dup.freeze
13
19
  super(message)
14
20
  end
21
+
22
+ def details
23
+ {
24
+ provider: provider && DiagnosticText.capture(provider.to_s, max_bytes: MAX_DETAIL_BYTES),
25
+ model_id: DiagnosticText.capture(model_id, max_bytes: MAX_DETAIL_BYTES),
26
+ source_error_class: DiagnosticText.capture(source_error_class, max_bytes: MAX_DETAIL_BYTES)
27
+ }.freeze
28
+ end
29
+
30
+ def self.from_details(details, message: nil)
31
+ new(message, **normalize_details(details))
32
+ end
33
+
34
+ def self.normalize_details(details)
35
+ raise ArgumentError, "provider failure details must be a Hash" unless details.is_a?(Hash)
36
+
37
+ values = {}
38
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
39
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
40
+ raise ArgumentError, "provider failure details contain an unknown attribute" unless DETAIL_NAMES.include?(name)
41
+ raise ArgumentError, "provider failure details contain a duplicate attribute" if values.key?(name)
42
+
43
+ values[name] = bounded_detail(name, value)
44
+ end
45
+ raise ArgumentError, "provider failure details are incomplete" unless values.length == DETAIL_NAMES.length
46
+
47
+ values
48
+ end
49
+
50
+ def self.bounded_detail(name, value)
51
+ return value if name == :provider && value.nil?
52
+
53
+ bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
54
+ raise ArgumentError, "provider failure detail #{name} must be bounded text" unless bounded
55
+
56
+ value
57
+ end
58
+ private_class_method :normalize_details, :bounded_detail
15
59
  end
16
60
  end
@@ -54,8 +54,16 @@ module Smith
54
54
  capture_failure(e, strict, reason: :capture_block_failed)
55
55
  end
56
56
 
57
+ # tool_call_id is additive only when present (a provider-batch
58
+ # invocation is ambient), so captures from direct invocations and
59
+ # pre-existing persisted payloads keep their exact two-key shape.
57
60
  def append_capture(collector, captured, strict)
58
- collector.call({ tool: name.to_s, captured: captured }) if strict || captured
61
+ return unless strict || captured
62
+
63
+ entry = { tool: name.to_s, captured: captured }
64
+ tool_call_id = self.class.current_invocation&.tool_call_id
65
+ entry[:tool_call_id] = tool_call_id if tool_call_id
66
+ collector.call(entry)
59
67
  rescue StandardError => e
60
68
  capture_failure(e, strict, reason: :collector_failed)
61
69
  end
data/lib/smith/tool.rb CHANGED
@@ -165,9 +165,18 @@ module Smith
165
165
  end
166
166
 
167
167
  def emit_tool_trace(kwargs, result, duration)
168
+ # tool_call_id is the provider's correlation id for this invocation
169
+ # (present only when the call came from a provider batch); it lets a
170
+ # host join this trace to its own per-invocation records.
168
171
  Smith::Trace.record(
169
172
  type: :tool_call,
170
- data: { tool: name, args: kwargs, result: result, duration: duration },
173
+ data: {
174
+ tool: name,
175
+ args: kwargs,
176
+ result: result,
177
+ duration: duration,
178
+ tool_call_id: self.class.current_invocation&.tool_call_id
179
+ }.compact,
171
180
  sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
172
181
  )
173
182
  end
@@ -7,6 +7,7 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
+ provider_call: :trace_provider_calls,
10
11
  cost: :trace_cost,
11
12
  normalizer_decision: :trace_normalizer
12
13
  }.freeze
@@ -7,26 +7,62 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
+ provider_call: :trace_provider_calls,
10
11
  cost: :trace_cost,
11
12
  normalizer_decision: :trace_normalizer
12
13
  }.freeze
13
14
 
14
15
  CONTENT_KEYS = %i[content prompt response args result].freeze
15
16
 
16
- attr_reader :traces
17
+ # Generous enough that test and development runs never hit it; a bound
18
+ # exists at all so a long-lived process with parallel branches cannot
19
+ # grow this adapter without limit.
20
+ DEFAULT_LIMIT = 10_000
17
21
 
18
- def initialize
22
+ attr_reader :traces, :limit
23
+
24
+ def initialize(limit: DEFAULT_LIMIT)
25
+ unless limit.is_a?(Integer) && limit.positive?
26
+ raise ArgumentError, "Smith::Trace::Memory limit must be a positive integer, got #{limit.inspect}"
27
+ end
28
+
29
+ @limit = limit
19
30
  @traces = []
31
+ @dropped_count = 0
32
+ @mutex = Mutex.new
20
33
  end
21
34
 
22
35
  def record(type:, data:)
23
36
  return unless type_enabled?(type)
24
37
 
25
- @traces << { type: type, data: filter_content(data) }
38
+ entry = { type: type, data: filter_content(data) }
39
+ @mutex.synchronize do
40
+ if @traces.length >= @limit
41
+ @dropped_count += 1
42
+ else
43
+ @traces << entry
44
+ end
45
+ end
46
+ end
47
+
48
+ # Entries rejected because the adapter was full. Zero in any healthy
49
+ # test run; a growing value means the limit needs raising or the
50
+ # process needs a clear!.
51
+ def dropped_count
52
+ @mutex.synchronize { @dropped_count }
53
+ end
54
+
55
+ # A consistent copy for readers that may race concurrent recording;
56
+ # #traces stays the live array for compatibility.
57
+ def snapshot
58
+ @mutex.synchronize { @traces.dup }
26
59
  end
27
60
 
28
61
  def clear!
29
- @traces = []
62
+ @mutex.synchronize do
63
+ @traces = []
64
+ @dropped_count = 0
65
+ end
30
66
  end
31
67
 
32
68
  private
@@ -7,7 +7,9 @@ module Smith
7
7
  transition: :trace_transitions,
8
8
  tool_call: :trace_tool_calls,
9
9
  token_usage: :trace_token_usage,
10
- cost: :trace_cost
10
+ provider_call: :trace_provider_calls,
11
+ cost: :trace_cost,
12
+ normalizer_decision: :trace_normalizer
11
13
  }.freeze
12
14
 
13
15
  CONTENT_KEYS = %i[content prompt response args result].freeze
@@ -23,18 +25,60 @@ module Smith
23
25
  )
24
26
  end
25
27
 
28
+ # Smith trace events describe operations that already finished, so the
29
+ # span is created retroactively: when the event carries a duration
30
+ # (:tool_call seconds, :provider_call milliseconds) the span's start is
31
+ # backdated by it and the span duration is real; otherwise the span is
32
+ # an instant. Uses only the documented opentelemetry-api surface
33
+ # (Tracer#start_span with start_timestamp, Span#finish with
34
+ # end_timestamp) so any SDK the host installs applies.
26
35
  def record(type:, data:)
27
36
  return unless @tracer
28
37
  return unless type_enabled?(type)
29
38
 
30
39
  filtered = filter_content(data)
31
- @tracer.in_span("smith.#{type}") do |span|
32
- filtered.each { |key, value| span.set_attribute("smith.#{key}", value.to_s) }
40
+ finished_at = Time.now
41
+ span = @tracer.start_span("smith.#{type}", start_timestamp: span_start(filtered, finished_at))
42
+ begin
43
+ apply_attributes(span, filtered)
44
+ ensure
45
+ span.finish(end_timestamp: finished_at)
33
46
  end
34
47
  end
35
48
 
36
49
  private
37
50
 
51
+ def span_start(data, finished_at)
52
+ seconds = duration_seconds(data)
53
+ seconds ? finished_at - seconds : finished_at
54
+ end
55
+
56
+ def duration_seconds(data)
57
+ return data[:duration].to_f if data[:duration].is_a?(Numeric)
58
+ return data[:duration_ms] / 1000.0 if data[:duration_ms].is_a?(Numeric)
59
+
60
+ nil
61
+ end
62
+
63
+ def apply_attributes(span, data)
64
+ data.each do |key, value|
65
+ coerced = attribute_value(value)
66
+ span.set_attribute("smith.#{key}", coerced) unless coerced.nil?
67
+ end
68
+ end
69
+
70
+ # OpenTelemetry attributes accept strings, integers, floats, and
71
+ # booleans; numeric values keep their type instead of arriving as
72
+ # strings, everything else (symbols included) becomes a string, nil
73
+ # drops.
74
+ def attribute_value(value)
75
+ case value
76
+ when String, Integer, Float, true, false then value
77
+ when nil then nil
78
+ else value.to_s
79
+ end
80
+ end
81
+
38
82
  def type_enabled?(type)
39
83
  config_key = CONFIG_MAP[type]
40
84
  return true unless config_key
data/lib/smith/trace.rb CHANGED
@@ -1,34 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "attribution"
4
+
3
5
  module Smith
4
6
  module Trace
5
7
  SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
8
+ ADAPTER_MUTEX = Mutex.new
9
+ private_constant :ADAPTER_MUTEX
6
10
 
7
11
  def self.record(type:, data:, sensitivity: :low)
8
12
  adapter = resolve_adapter
9
13
  return unless adapter
10
14
 
11
- filtered = apply_content_policy(data, sensitivity)
15
+ filtered = apply_content_policy(attributed(data), sensitivity)
12
16
  filtered = filter_fields(type, filtered)
13
17
  adapter.record(type: type, data: filtered)
14
18
  rescue StandardError => e
15
19
  Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
16
20
  end
17
21
 
22
+ # Ambient attribution keys are identifiers, not content: they merge in
23
+ # under the caller's own keys (the caller wins on conflict) and then pass
24
+ # through the same content policy and field allowlist as everything else,
25
+ # so a host's configured trace_fields contract keeps holding.
26
+ def self.attributed(data)
27
+ return data unless Smith.config.trace_attribution
28
+
29
+ fields = Attribution.current_fields
30
+ return data if fields.empty?
31
+
32
+ fields.merge(data)
33
+ end
34
+
35
+ # Class-configured adapters memoize one instance under a mutex so
36
+ # concurrent first records (every fan-out branch emits) share a single
37
+ # adapter instead of racing separate instances and losing entries.
18
38
  def self.resolve_adapter
19
39
  configured = Smith.config.trace_adapter
20
40
  return nil unless configured
41
+ return configured unless configured.is_a?(Class)
21
42
 
22
- if configured.is_a?(Class)
43
+ ADAPTER_MUTEX.synchronize do
23
44
  @adapter_instances ||= {}
24
45
  @adapter_instances[configured] ||= configured.new
25
- else
26
- configured
27
46
  end
28
47
  end
29
48
 
30
49
  def self.reset!
31
- @adapter_instances = nil
50
+ ADAPTER_MUTEX.synchronize { @adapter_instances = nil }
32
51
  end
33
52
 
34
53
  def self.apply_content_policy(data, sensitivity)
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.8.0"
4
+ VERSION = "0.11.0"
5
5
  EXECUTION_SEMANTICS_VERSION = "5"
6
6
  end
@@ -22,7 +22,7 @@ module Smith
22
22
  return nil unless ledger
23
23
 
24
24
  ledger.limits.each_with_object({}) do |(dim, _limit), est|
25
- per_branch = estimate_for_dimension(dim, ledger.remaining(dim), branch_count)
25
+ per_branch = estimate_for_dimension(dim, ledger.remaining_share(dim, branch_count))
26
26
  cap = agent_cap_for_dimension(dim, agent_budget)
27
27
  est[dim] = cap ? [per_branch, cap].min : per_branch
28
28
  end
@@ -117,10 +117,10 @@ module Smith
117
117
  { branch: branch_key, agent: agent_name, output: agent_result ? agent_result.content : result }
118
118
  end
119
119
 
120
- def estimate_for_dimension(dim, limit, branch_count)
120
+ def estimate_for_dimension(dim, share)
121
121
  return 0 unless BUDGET_DIMENSIONS.include?(dim)
122
122
 
123
- TOKEN_DIMENSIONS.include?(dim) ? [limit / branch_count, 1].max : limit / branch_count
123
+ TOKEN_DIMENSIONS.include?(dim) ? [share, 1].max : share
124
124
  end
125
125
 
126
126
  def agent_cap_for_dimension(dim, agent_budget)
@@ -4,19 +4,32 @@ require_relative "../../types"
4
4
  require_relative "../../budget/decimal_context"
5
5
  require_relative "../message_value_normalizer"
6
6
  require_relative "../prepared_step"
7
+ require_relative "../usage_entry"
7
8
  require_relative "payload"
8
9
 
9
10
  module Smith
10
11
  class Workflow
11
12
  module Composite
12
- class Effects < Payload
13
+ # Length is fail-closed value validation, not logic: every key the
14
+ # contract admits gets a bounded value check beside the contract that
15
+ # admits it. Splitting the checks away from the payload they guard
16
+ # would trade cohesion for a metric.
17
+ class Effects < Payload # rubocop:disable Metrics/ClassLength
13
18
  attr_reader :total_tokens, :total_cost
14
19
 
15
- USAGE_ATTRIBUTES = %w[
16
- usage_id agent_name model provider input_tokens output_tokens cost attempt_kind recorded_at
17
- ].freeze
18
- TOOL_ATTRIBUTES = %w[tool captured].freeze
19
- private_constant :USAGE_ATTRIBUTES, :TOOL_ATTRIBUTES
20
+ # Required keys are the pre-attribution UsageEntry shape, so effects
21
+ # produced by an older Smith (a mid-deploy branch worker or a
22
+ # restored checkpoint) stay valid; allowed keys are the current
23
+ # member set, so unknown keys still reject. The attribution members
24
+ # (transition, branch_key, round, attempt_id) are optional by
25
+ # construction.
26
+ USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
27
+ USAGE_REQUIRED_ATTRIBUTES =
28
+ (USAGE_ALLOWED_ATTRIBUTES - %w[transition branch_key round attempt_id workflow]).freeze
29
+ TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
30
+ TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
31
+ private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
32
+ :TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
20
33
 
21
34
  attribute :usage_entries, Types::Array
22
35
  attribute :tool_results, Types::Array
@@ -25,17 +38,12 @@ module Smith
25
38
  def initialize(attributes)
26
39
  owned = self.class.normalize_attributes(attributes)
27
40
  normalized = MessageValueNormalizer.new(owned, label: "composite effects").call
28
- usage_entries = normalized.fetch("usage_entries")
29
- tool_results = normalized.fetch("tool_results")
30
- budget_consumed = normalized.fetch("budget_consumed")
41
+ usage_entries, tool_results, budget_consumed =
42
+ normalized.values_at("usage_entries", "tool_results", "budget_consumed")
31
43
  @total_tokens, @total_cost = validate_usage_entries!(usage_entries)
32
44
  validate_tool_results!(tool_results)
33
45
  validate_budget!(budget_consumed)
34
- super(
35
- usage_entries:,
36
- tool_results:,
37
- budget_consumed:
38
- )
46
+ super(usage_entries:, tool_results:, budget_consumed:)
39
47
  end
40
48
 
41
49
  private
@@ -44,8 +52,9 @@ module Smith
44
52
  raise ArgumentError, "composite usage entries must be an Array" unless entries.is_a?(Array)
45
53
 
46
54
  entries.each do |entry|
47
- validate_exact_keys!(entry, USAGE_ATTRIBUTES, "composite usage entry")
55
+ validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
48
56
  validate_usage_identity!(entry)
57
+ validate_usage_attribution!(entry)
49
58
  validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
50
59
  validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
51
60
  validate_cost!(entry.fetch("cost"))
@@ -68,25 +77,40 @@ module Smith
68
77
  end
69
78
 
70
79
  def validate_usage_identity!(entry)
71
- validate_usage_id!(entry.fetch("usage_id"))
72
- validate_agent_name!(entry.fetch("agent_name"))
73
- provider = entry.fetch("provider")
74
- validate_nonempty_string!(provider, "composite usage entry provider") if provider
80
+ validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
81
+ # agent_name and provider are nil-allowed; any other value
82
+ # (false included) must be a non-empty String.
83
+ %w[agent_name provider].each do |key|
84
+ value = entry.fetch(key)
85
+ validate_nonempty_string!(value, "composite usage entry #{key}") unless value.nil?
86
+ end
75
87
  %w[model attempt_kind recorded_at].each do |key|
76
88
  validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
77
89
  end
78
90
  end
79
91
 
80
- def validate_usage_id!(usage_id)
81
- return if usage_id.is_a?(String) && PreparedStep::UUID_PATTERN.match?(usage_id)
92
+ # The optional attribution keys are bounded values, not just bounded
93
+ # keys: a present key with a wrong-typed, empty, or oversized value
94
+ # rejects exactly like the identity fields do. Absent keys (older
95
+ # producers, or nil-omitting serialization) stay valid.
96
+ def validate_usage_attribution!(entry)
97
+ %w[transition branch_key workflow].each do |key|
98
+ validate_bounded_string!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
99
+ end
100
+ validate_usage_amount!(entry.fetch("round"), "round") if entry.key?("round")
101
+ validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
102
+ end
103
+
104
+ def validate_uuid!(value, label)
105
+ return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
82
106
 
83
- raise ArgumentError, "composite usage entry usage_id must be a UUID"
107
+ raise ArgumentError, "#{label} must be a UUID"
84
108
  end
85
109
 
86
- def validate_agent_name!(agent_name)
87
- return if agent_name.nil?
110
+ def validate_bounded_string!(value, label, max_length)
111
+ return if value.is_a?(String) && value.length.between?(1, max_length)
88
112
 
89
- validate_nonempty_string!(agent_name, "composite usage entry agent_name")
113
+ raise ArgumentError, "#{label} must be a bounded non-empty String"
90
114
  end
91
115
 
92
116
  def validate_nonempty_string!(value, label)
@@ -102,8 +126,7 @@ module Smith
102
126
  end
103
127
 
104
128
  def validate_cost!(cost)
105
- return if cost.nil?
106
- return if cost.is_a?(Numeric) && cost.finite? && cost >= 0
129
+ return if cost.nil? || (cost.is_a?(Numeric) && cost.finite? && cost >= 0)
107
130
 
108
131
  raise ArgumentError, "composite usage entry cost must be a finite non-negative number or nil"
109
132
  end
@@ -112,10 +135,15 @@ module Smith
112
135
  raise ArgumentError, "composite tool results must be an Array" unless entries.is_a?(Array)
113
136
 
114
137
  entries.each do |entry|
115
- validate_exact_keys!(entry, TOOL_ATTRIBUTES, "composite tool result")
116
- tool = entry.fetch("tool")
117
- unless tool.is_a?(String) && tool.length.between?(1, 256)
118
- raise ArgumentError, "composite tool result tool must be a bounded non-empty String"
138
+ validate_bounded_keys!(entry, TOOL_REQUIRED_ATTRIBUTES, TOOL_ALLOWED_ATTRIBUTES, "composite tool result")
139
+ validate_bounded_string!(entry.fetch("tool"), "composite tool result tool", 256)
140
+
141
+ # Present only for provider-batch invocations; the producer never
142
+ # writes a nil, so a present key must carry a real id. Provider
143
+ # tool-call ids are short strings; 1024 is far above any observed
144
+ # provider format while still bounding the payload.
145
+ if entry.key?("tool_call_id")
146
+ validate_bounded_string!(entry.fetch("tool_call_id"), "composite tool result tool_call_id", 1024)
119
147
  end
120
148
  end
121
149
  end
@@ -130,9 +158,12 @@ module Smith
130
158
  end
131
159
  end
132
160
 
133
- def validate_exact_keys!(value, expected, label)
161
+ # Every required key present, no key outside the allowed set: older
162
+ # producers (missing optional keys) pass, unknown keys still reject.
163
+ # Passing the same set for both is an exact-keys check.
164
+ def validate_bounded_keys!(value, required, allowed, label)
134
165
  raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
135
- return if value.keys.sort == expected.sort
166
+ return if (required - value.keys).empty? && (value.keys - allowed).empty?
136
167
 
137
168
  raise ArgumentError, "#{label} attributes are invalid"
138
169
  end
@@ -29,7 +29,8 @@ module Smith
29
29
  session_messages: snapshot_value(@session_messages || []),
30
30
  tool_results: snapshot_value(@tool_results || []),
31
31
  state: @state,
32
- transition: transition
32
+ transition: transition,
33
+ last_agent_execution: snapshot_value(@last_agent_execution)
33
34
  )
34
35
  end
35
36
 
@@ -37,7 +38,7 @@ module Smith
37
38
  @context.merge!(step.context_writes)
38
39
  step.context_writes.each_key { |key| record_persisted_key!(key) }
39
40
  @router_next_transition = step.routed_to if step.routed_to
40
- @outcome = snapshot_value(step.outcome) if step.outcome
41
+ @outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
41
42
  end
42
43
 
43
44
  def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
@@ -15,15 +15,28 @@ module Smith
15
15
  @current_state = state
16
16
  @transition_name = transition ? transition.name : options.fetch(:transition_name)
17
17
  @allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
18
+ @last_agent_execution = options[:last_agent_execution]
18
19
  @context_writes = {}
19
20
  @routed_to = nil
20
21
  @outcome = nil
21
22
  end
22
23
 
24
+ # The model id / provider that actually served the most recent serial
25
+ # `execute :agent` step (post fallback resolution), or nil if no serial
26
+ # agent step has run. Symmetric with `last_output`, which returns that
27
+ # step's content; use these to attribute an agent output to its model.
28
+ def last_agent_model
29
+ @last_agent_execution && @last_agent_execution[:model]
30
+ end
31
+
32
+ def last_agent_provider
33
+ @last_agent_execution && @last_agent_execution[:provider]
34
+ end
35
+
23
36
  def last_output
24
37
  return @last_output if defined?(@last_output)
25
38
 
26
- msg = session_messages.reverse.find { |m| m[:role] == :assistant || m["role"] == "assistant" }
39
+ msg = session_messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "assistant" }
27
40
  @last_output = msg && (msg[:content] || msg["content"])
28
41
  end
29
42
 
@@ -59,7 +72,7 @@ module Smith
59
72
  private
60
73
 
61
74
  def validate_options!(options)
62
- unknown = options.keys - %i[transition transition_name allowed_routes]
75
+ unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution]
63
76
  raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
64
77
  return if options[:transition] || options.key?(:transition_name)
65
78
 
@@ -3,6 +3,9 @@
3
3
  module Smith
4
4
  class Workflow
5
5
  module DSL
6
+ BUDGET_KEYS = %i[total_tokens token_limit total_cost tool_calls wall_clock].freeze
7
+ private_constant :BUDGET_KEYS
8
+
6
9
  def self.included(base)
7
10
  base.extend(ClassMethods)
8
11
  end
@@ -17,6 +20,7 @@ module Smith
17
20
  duplicate_transition_index(@transitions_by_state)
18
21
  )
19
22
  subclass.instance_variable_set(:@transition_order, (@transition_order || {}).dup)
23
+ subclass.instance_variable_set(:@generated_transitions, (@generated_transitions || []).dup)
20
24
  subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
21
25
  subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
22
26
  subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
@@ -50,7 +54,12 @@ module Smith
50
54
  declared = Transition.new(name, from: from, to: to, &)
51
55
  name = declared.name
52
56
  @transitions ||= {}
53
- remove_from_transition_index(@transitions[name]) if @transitions.key?(name)
57
+
58
+ if @transitions.key?(name)
59
+ remove_from_transition_index(@transitions[name])
60
+ release_generated_transition_order(name)
61
+ end
62
+
54
63
  @transitions[name] = declared
55
64
  insert_into_transition_index(declared)
56
65
  end
@@ -58,6 +67,12 @@ module Smith
58
67
  def budget(**opts)
59
68
  return @budget_config if opts.empty?
60
69
 
70
+ unknown = opts.keys - BUDGET_KEYS
71
+ unless unknown.empty?
72
+ raise ArgumentError, "workflow budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
73
+ "accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
74
+ end
75
+
61
76
  @budget_config = opts
62
77
  end
63
78
 
@@ -190,11 +205,12 @@ module Smith
190
205
  #
191
206
  # Wiring contract: when the resolved TTL is non-nil,
192
207
  # Workflow#persist! forwards it to the adapter as a `ttl:`
193
- # kwarg. Shipped adapters (Memory, RedisStore, CacheStore,
194
- # ActiveRecordStore) accept this kwarg; external duck-typed
195
- # adapters that implement only the bare REQUIRED_METHODS contract
196
- # without a `ttl:` kwarg will only break when a host actually
197
- # opts into TTL.
208
+ # kwarg. Memory, RedisStore, and CacheStore honour it;
209
+ # ActiveRecordStore cannot expire state and refuses a non-nil TTL
210
+ # before writing, which a persisted run reaches before its first
211
+ # step. External duck-typed adapters that implement only the bare
212
+ # REQUIRED_METHODS contract without a `ttl:` kwarg will only break
213
+ # when a host actually opts into TTL.
198
214
  def persistence_ttl(seconds = nil)
199
215
  return @persistence_ttl if seconds.nil?
200
216
 
@@ -279,10 +295,28 @@ module Smith
279
295
  transitions_by_state.delete(transition.from) if indexed.empty?
280
296
  end
281
297
 
298
+ # A user redeclaration replacing a GENERATED transition takes a fresh
299
+ # declaration-position order; a genuine user redefinition keeps its
300
+ # original position (matrix-pinned redefinition semantics). Without
301
+ # this, the synthetic :fail generated when `state :failed` is
302
+ # declared keeps its early order number, and a later user-declared
303
+ # :fail sharing an origin state with a primary transition would sort
304
+ # ahead of it and shadow it at run time.
305
+ def release_generated_transition_order(name)
306
+ return unless generated_transitions.delete(name)
307
+
308
+ transition_order.delete(name)
309
+ end
310
+
311
+ def generated_transitions
312
+ @generated_transitions ||= []
313
+ end
314
+
282
315
  def generate_fail_transition
283
316
  @transitions ||= {}
284
317
  return if @transitions.key?(:fail)
285
318
 
319
+ generated_transitions << :fail
286
320
  transition(:fail, from: nil, to: :failed)
287
321
  end
288
322
  end
@@ -389,7 +389,7 @@ module Smith
389
389
  def invoke_on_step_callback(step, callback)
390
390
  callback&.call(step)
391
391
  rescue StandardError => e
392
- Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message}")
392
+ Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message} (#{e.class})")
393
393
  end
394
394
  end
395
395
  end