smith-agents 0.10.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +203 -0
  3. data/README.md +16 -6
  4. data/docs/CONFIGURATION.md +21 -1
  5. data/docs/PATTERNS.md +59 -0
  6. data/docs/PERSISTENCE.md +5 -1
  7. data/lib/smith/agent/fallback_configuration.rb +26 -3
  8. data/lib/smith/agent/provider_attempt.rb +11 -4
  9. data/lib/smith/agent/provider_call_timing.rb +62 -7
  10. data/lib/smith/agent/provider_completion.rb +8 -8
  11. data/lib/smith/agent/provider_usage.rb +6 -0
  12. data/lib/smith/agent/usage_tracking.rb +3 -0
  13. data/lib/smith/agent.rb +9 -1
  14. data/lib/smith/budget/amount_representation.rb +20 -0
  15. data/lib/smith/budget/ledger.rb +6 -2
  16. data/lib/smith/context/state_injection.rb +12 -2
  17. data/lib/smith/diagnostic_text.rb +10 -1
  18. data/lib/smith/doctor/checks/durability.rb +16 -0
  19. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  20. data/lib/smith/errors.rb +55 -4
  21. data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
  22. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  23. data/lib/smith/persistence_adapters.rb +10 -0
  24. data/lib/smith/provider_permanent_failure.rb +44 -0
  25. data/lib/smith/version.rb +1 -1
  26. data/lib/smith/workflow/budget_integration.rb +3 -3
  27. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  28. data/lib/smith/workflow/deterministic_step.rb +1 -1
  29. data/lib/smith/workflow/dsl.rb +15 -5
  30. data/lib/smith/workflow/durability.rb +1 -1
  31. data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
  32. data/lib/smith/workflow/execution.rb +2 -0
  33. data/lib/smith/workflow/failure_reconstructor.rb +18 -2
  34. data/lib/smith/workflow/failure_record.rb +6 -11
  35. data/lib/smith/workflow/failure_record_validator.rb +23 -3
  36. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  37. data/lib/smith/workflow/optimization_state.rb +2 -2
  38. data/lib/smith/workflow/persistence.rb +26 -9
  39. data/lib/smith/workflow/retry_execution.rb +4 -0
  40. data/lib/smith/workflow/run_result.rb +10 -0
  41. data/lib/smith/workflow/step_completion.rb +4 -0
  42. data/lib/smith/workflow.rb +5 -3
  43. data/lib/smith.rb +2 -2
  44. metadata +1 -1
data/lib/smith/agent.rb CHANGED
@@ -11,9 +11,10 @@ module Smith
11
11
  EXECUTION_IDENTITY_UNSET = Object.new.freeze
12
12
  TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
13
13
  TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
14
+ BUDGET_KEYS = %i[token_limit cost wall_clock tool_calls total_tokens total_cost].freeze
14
15
 
15
16
  private_constant :EXECUTION_IDENTITY_UNSET
16
- private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
17
+ private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES, :BUDGET_KEYS
17
18
 
18
19
  # Reserved input names auto-injected by the normalizer into
19
20
  # runtime_context. User-side `inputs :name` calls cannot redeclare
@@ -30,6 +31,7 @@ module Smith
30
31
  subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
31
32
  subclass.instance_variable_set(:@data_volume, @data_volume)
32
33
  subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
34
+ subclass.instance_variable_set(:@fallback_models_block, @fallback_models_block)
33
35
  subclass.instance_variable_set(:@model_block, @model_block)
34
36
  subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
35
37
  subclass.instance_variable_set(:@execution_identity, nil)
@@ -48,6 +50,12 @@ module Smith
48
50
  def budget(**opts)
49
51
  return @budget_config if opts.empty?
50
52
 
53
+ unknown = opts.keys - BUDGET_KEYS
54
+ unless unknown.empty?
55
+ raise ArgumentError, "agent budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
56
+ "accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
57
+ end
58
+
51
59
  @budget_config = opts
52
60
  end
53
61
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bigdecimal"
4
4
  require "dry-initializer"
5
+ require_relative "decimal_context"
5
6
 
6
7
  module Smith
7
8
  module Budget
@@ -26,6 +27,25 @@ module Smith
26
27
  finite_float!(amount)
27
28
  end
28
29
 
30
+ # Remaining capacity, or one of parts equal shares of it, is reserved as
31
+ # read, so parts reservations of the external value must never exceed the
32
+ # exact amount. An Integer keeps floor division; a Float steps down until
33
+ # its decimal form fits, which takes O(1) steps because Float division
34
+ # lands within one ulp of the exact share.
35
+ def externalize_remaining(key, amount, parts = 1)
36
+ unless parts.is_a?(Integer) && parts.positive?
37
+ raise ArgumentError, "budget share parts must be a positive Integer"
38
+ end
39
+
40
+ share = externalize_amount(key, amount) / parts
41
+ return share unless share.is_a?(Float)
42
+
43
+ DecimalContext.call do
44
+ share = share.prev_float while internalize_amount(share) * parts > amount
45
+ end
46
+ share
47
+ end
48
+
29
49
  private
30
50
 
31
51
  def internalize_amount(amount)
@@ -58,12 +58,16 @@ module Smith
58
58
  @mutex.synchronize { release_reservation(token) }
59
59
  end
60
60
 
61
- def remaining(key)
61
+ def remaining(key) = remaining_share(key, 1)
62
+
63
+ # One of parts equal shares of the remaining amount, taken from the exact
64
+ # internal value so that parts reservations of it always fit.
65
+ def remaining_share(key, parts)
62
66
  dimension = @amount_contract.dimension!(key)
63
67
  @amount_contract.known_dimension!(dimension)
64
68
  @mutex.synchronize do
65
69
  amount = @state_transition.remaining(@publication.state, dimension)
66
- @representation.externalize_amount(dimension, amount)
70
+ @representation.externalize_remaining(dimension, amount, parts)
67
71
  end
68
72
  end
69
73
 
@@ -6,19 +6,29 @@ module Smith
6
6
  MARKER = "[smith:injected-state]"
7
7
 
8
8
  def self.inject(messages, formatter:, persisted:)
9
- content = "#{MARKER}\n#{formatter.call(persisted)}"
9
+ text = formatter.call(persisted).to_s
10
+ content = "#{MARKER}\n#{text}"
10
11
 
11
12
  existing_index = messages.index do |message|
12
13
  message_content = message[:content] || message["content"]
13
14
  message_content.is_a?(String) && message_content.start_with?(MARKER)
14
15
  end
15
16
 
16
- if existing_index
17
+ if blank?(text)
18
+ without_marker(messages, existing_index)
19
+ elsif existing_index
17
20
  messages.dup.tap { |msgs| msgs[existing_index] = { role: :system, content: content } }
18
21
  else
19
22
  messages + [{ role: :system, content: content }]
20
23
  end
21
24
  end
25
+
26
+ def self.blank?(text) = text.valid_encoding? && text.strip.empty?
27
+
28
+ def self.without_marker(messages, index)
29
+ index ? messages.dup.tap { |msgs| msgs.delete_at(index) } : messages
30
+ end
31
+ private_class_method :blank?, :without_marker
22
32
  end
23
33
  end
24
34
  end
@@ -9,7 +9,10 @@ module Smith
9
9
  STRING_BYTESIZE = String.instance_method(:bytesize)
10
10
  STRING_BYTESLICE = String.instance_method(:byteslice)
11
11
  MODULE_MATCH = Module.instance_method(:===)
12
- private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH
12
+ MODULE_NAME = Module.instance_method(:name)
13
+ OBJECT_CLASS = Object.instance_method(:class)
14
+ private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH, :MODULE_NAME,
15
+ :OBJECT_CLASS
13
16
 
14
17
  extend Dry::Initializer
15
18
 
@@ -18,6 +21,12 @@ module Smith
18
21
 
19
22
  def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
20
23
 
24
+ # An error's class name, read through the native lookups so the error
25
+ # cannot misreport it, and bounded like any diagnostic identifier.
26
+ def self.error_class_name(error)
27
+ capture(MODULE_NAME.bind_call(OBJECT_CLASS.bind_call(error)) || "anonymous_error", max_bytes: 512)
28
+ end
29
+
21
30
  def call
22
31
  validate_limit!
23
32
  text = utf8_prefix
@@ -31,10 +31,26 @@ module Smith
31
31
  )
32
32
  return
33
33
  else
34
+ check_ttl_support(report, adapter)
34
35
  check_persist_and_restore(report, adapter)
35
36
  check_resume_after_restore(report, adapter)
36
37
  end
37
38
 
39
+ # ActiveRecordStore refuses every write that carries a TTL, so a
40
+ # configured global TTL would fail the first persisted run.
41
+ def self.check_ttl_support(report, adapter)
42
+ ttl = ::Smith.config.persistence_ttl
43
+ return if ttl.nil? || !adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
44
+
45
+ report.add(
46
+ name: "durability.ttl",
47
+ status: :fail,
48
+ message: "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil",
49
+ detail: "Smith.config.persistence_ttl is #{ttl.inspect}, so every persisted write refuses it. " \
50
+ "Set it to nil, or use an adapter that honours TTL."
51
+ )
52
+ end
53
+
38
54
  def self.check_persist_and_restore(report, adapter)
39
55
  workflow_class = build_probe_class
40
56
  payload = JSON.generate(workflow_class.new.to_state)
@@ -17,7 +17,10 @@ module Smith
17
17
  OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
18
18
 
19
19
  def run(report)
20
- adapter = resolve_adapter
20
+ adapter = Smith.persistence_adapter
21
+ rescue StandardError => e
22
+ report_invalid_adapter(report, e)
23
+ else
21
24
  return report_missing_adapter(report) if adapter.nil?
22
25
 
23
26
  missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
@@ -26,12 +29,6 @@ module Smith
26
29
  report_missing_capabilities(report, adapter, missing)
27
30
  end
28
31
 
29
- def resolve_adapter
30
- Smith.persistence_adapter
31
- rescue StandardError
32
- nil
33
- end
34
-
35
32
  def capability_available?(adapter, capability)
36
33
  return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
37
34
  return true unless capability == :persistence_identity
@@ -42,6 +39,15 @@ module Smith
42
39
  false
43
40
  end
44
41
 
42
+ def report_invalid_adapter(report, error)
43
+ report.add(
44
+ name: "persistence.capabilities",
45
+ status: :fail,
46
+ message: "Persistence adapter configuration is invalid",
47
+ detail: error.message
48
+ )
49
+ end
50
+
45
51
  def report_missing_adapter(report)
46
52
  report.add(
47
53
  name: "persistence.capabilities",
data/lib/smith/errors.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "diagnostic_text"
3
4
  require_relative "error"
4
5
  require_relative "persisted_failure_invalid"
5
6
  require_relative "pricing_configuration_error"
@@ -88,6 +89,11 @@ module Smith
88
89
  class AgentError < Error; end
89
90
 
90
91
  class BlankAgentOutputError < AgentError
92
+ DETAIL_NAMES = %i[agent_name model_used].freeze
93
+ DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
94
+ MAX_DETAIL_BYTES = 512
95
+ private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
96
+
91
97
  attr_reader :agent_name, :model_used
92
98
 
93
99
  def initialize(agent_name:, model_used:)
@@ -101,6 +107,46 @@ module Smith
101
107
 
102
108
  super(detail)
103
109
  end
110
+
111
+ def details
112
+ {
113
+ agent_name: agent_name && DiagnosticText.capture(agent_name.to_s, max_bytes: MAX_DETAIL_BYTES),
114
+ model_used: model_used && DiagnosticText.capture(model_used.to_s, max_bytes: MAX_DETAIL_BYTES)
115
+ }.freeze
116
+ end
117
+
118
+ def self.from_details(details)
119
+ values = normalize_details(details)
120
+ new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used))
121
+ end
122
+
123
+ def self.normalize_details(details)
124
+ raise ArgumentError, "blank agent output details must be a Hash" unless details.is_a?(Hash)
125
+
126
+ values = {}
127
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
128
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
129
+ unless DETAIL_NAMES.include?(name)
130
+ raise ArgumentError, "blank agent output details contain an unknown attribute"
131
+ end
132
+ raise ArgumentError, "blank agent output details contain a duplicate attribute" if values.key?(name)
133
+
134
+ values[name] = bounded_detail(name, value)
135
+ end
136
+ raise ArgumentError, "blank agent output details are incomplete" unless values.length == DETAIL_NAMES.length
137
+
138
+ values
139
+ end
140
+
141
+ def self.bounded_detail(name, value)
142
+ return value if value.nil?
143
+
144
+ bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
145
+ raise ArgumentError, "blank agent output detail #{name} must be bounded text" unless bounded
146
+
147
+ value
148
+ end
149
+ private_class_method :normalize_details, :bounded_detail
104
150
  end
105
151
 
106
152
  class WorkflowError < Error; end
@@ -185,15 +231,20 @@ module Smith
185
231
  # `idempotency_mode :strict`. Signals that a previous worker crashed
186
232
  # between `persist!` (before advance) and `persist!` (after advance);
187
233
  # the step's effects are unknown, so blindly re-running could
188
- # double-execute non-idempotent agent calls or tools.
234
+ # double-execute non-idempotent agent calls or tools. `state` is the
235
+ # persisted state the interrupted step started from and `transition` the
236
+ # next transition the payload records; each is nil when unknown.
189
237
  class StepInProgressOnRestore < Error
190
- attr_reader :workflow, :persistence_key
238
+ attr_reader :workflow, :persistence_key, :state, :transition
191
239
 
192
- def initialize(workflow:, persistence_key:)
240
+ def initialize(workflow:, persistence_key:, state: nil, transition: nil)
193
241
  @workflow = workflow
194
242
  @persistence_key = persistence_key
243
+ @state = state.to_sym if state.is_a?(String) || state.is_a?(Symbol)
244
+ @transition = transition.to_sym if transition.is_a?(String) || transition.is_a?(Symbol)
195
245
  super(
196
- "step in progress on restore for #{workflow} key=#{persistence_key.inspect}: " \
246
+ "step in progress on restore for #{workflow} key=#{persistence_key.inspect}" \
247
+ "#{" state=#{@state.inspect}" if @state}: " \
197
248
  "a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
198
249
  "decide whether to clear the persisted state (idempotent re-run unsafe) or " \
199
250
  "switch to :lax (assume re-run is safe)."
@@ -5,9 +5,8 @@ module Smith
5
5
  module ActiveRecordExactStore
6
6
  attr_reader :persistence_identity
7
7
 
8
- def replace_exact(key, payload, expected_payload:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
9
- ensure_version_column!
10
- ensure_locking_configuration!
8
+ def replace_exact(key, payload, expected_payload:, ttl: nil)
9
+ ensure_versioned_write!(ttl)
11
10
  model = model_class
12
11
  ensure_exact_schema!(model)
13
12
  ActiveRecordExactWrite.new(
@@ -25,6 +24,12 @@ module Smith
25
24
 
26
25
  private
27
26
 
27
+ def ensure_versioned_write!(ttl)
28
+ PersistenceAdapters.refuse_active_record_ttl!(ttl)
29
+ ensure_version_column!
30
+ ensure_locking_configuration!
31
+ end
32
+
28
33
  def ensure_exact_schema!(model)
29
34
  ensure_exact_payload_column!(model)
30
35
  ensure_exact_key_uniqueness!(model)
@@ -16,10 +16,8 @@ module Smith
16
16
  @persistence_identity = identity.to_s.dup.freeze if identity
17
17
  end
18
18
 
19
- def store(key, payload, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
20
- # TTL is deferred for ActiveRecordStore — would require an
21
- # `expires_at` column + a periodic sweeper job. Ignored here;
22
- # documented as a known limitation.
19
+ def store(key, payload, ttl: nil)
20
+ PersistenceAdapters.refuse_active_record_ttl!(ttl)
23
21
  Retry.with_retries(operation: :store, transient: ActiveRecordConnectionErrors.classes) do
24
22
  record = model_class.find_or_initialize_by(@key_column => key)
25
23
  record.public_send(:"#{@payload_column}=", payload)
@@ -47,9 +45,8 @@ module Smith
47
45
  # `lock_version` column. Requires the AR model to have a
48
46
  # `lock_version` (or configured) integer column with default 0.
49
47
  # If absent, raises ArgumentError directing the host to migrate.
50
- def store_versioned(key, payload, expected_version:, ttl: nil) # rubocop:disable Lint/UnusedMethodArgument
51
- ensure_version_column!
52
- ensure_locking_configuration!
48
+ def store_versioned(key, payload, expected_version:, ttl: nil)
49
+ ensure_versioned_write!(ttl)
53
50
 
54
51
  write_versioned(key, payload, expected_version)
55
52
  rescue *ActiveRecordConnectionErrors.classes => e
@@ -71,6 +71,16 @@ module Smith
71
71
  end
72
72
  private_class_method :build
73
73
 
74
+ # ActiveRecordStore's host table has no expiry column, so a TTL could
75
+ # only be ignored. Its writes fail closed instead of silently keeping
76
+ # state forever; reads and deletes never take a TTL and keep working.
77
+ def self.refuse_active_record_ttl!(ttl)
78
+ return if ttl.nil?
79
+
80
+ raise ArgumentError,
81
+ "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil, got #{ttl.inspect}"
82
+ end
83
+
74
84
  # Capability introspection used by Workflow#persist! to decide
75
85
  # whether the adapter supports optimistic locking via store_versioned.
76
86
  def self.supports?(adapter, capability)
@@ -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
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.10.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)
@@ -38,7 +38,7 @@ module Smith
38
38
  @context.merge!(step.context_writes)
39
39
  step.context_writes.each_key { |key| record_persisted_key!(key) }
40
40
  @router_next_transition = step.routed_to if step.routed_to
41
- @outcome = snapshot_value(step.outcome) if step.outcome
41
+ @outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
42
42
  end
43
43
 
44
44
  def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
@@ -36,7 +36,7 @@ module Smith
36
36
  def last_output
37
37
  return @last_output if defined?(@last_output)
38
38
 
39
- 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" }
40
40
  @last_output = msg && (msg[:content] || msg["content"])
41
41
  end
42
42
 
@@ -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
@@ -64,6 +67,12 @@ module Smith
64
67
  def budget(**opts)
65
68
  return @budget_config if opts.empty?
66
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
+
67
76
  @budget_config = opts
68
77
  end
69
78
 
@@ -196,11 +205,12 @@ module Smith
196
205
  #
197
206
  # Wiring contract: when the resolved TTL is non-nil,
198
207
  # Workflow#persist! forwards it to the adapter as a `ttl:`
199
- # kwarg. Shipped adapters (Memory, RedisStore, CacheStore,
200
- # ActiveRecordStore) accept this kwarg; external duck-typed
201
- # adapters that implement only the bare REQUIRED_METHODS contract
202
- # without a `ttl:` kwarg will only break when a host actually
203
- # 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.
204
214
  def persistence_ttl(seconds = nil)
205
215
  return @persistence_ttl if seconds.nil?
206
216
 
@@ -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