smith-agents 0.4.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 (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Trace
5
+ SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
6
+
7
+ def self.record(type:, data:, sensitivity: :low)
8
+ adapter = resolve_adapter
9
+ return unless adapter
10
+
11
+ filtered = apply_content_policy(data, sensitivity)
12
+ filtered = filter_fields(type, filtered)
13
+ adapter.record(type: type, data: filtered)
14
+ rescue StandardError => e
15
+ Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
16
+ end
17
+
18
+ def self.resolve_adapter
19
+ configured = Smith.config.trace_adapter
20
+ return nil unless configured
21
+
22
+ if configured.is_a?(Class)
23
+ @adapter_instances ||= {}
24
+ @adapter_instances[configured] ||= configured.new
25
+ else
26
+ configured
27
+ end
28
+ end
29
+
30
+ def self.reset!
31
+ @adapter_instances = nil
32
+ end
33
+
34
+ def self.apply_content_policy(data, sensitivity)
35
+ case Smith.config.trace_content
36
+ when true
37
+ apply_sensitivity(data, sensitivity)
38
+ when :redacted
39
+ apply_sensitivity(redact_sensitive_keys(data), sensitivity)
40
+ else
41
+ data.except(*SENSITIVITY_CONTENT_KEYS)
42
+ end
43
+ end
44
+
45
+ def self.apply_sensitivity(data, sensitivity)
46
+ case sensitivity
47
+ when :high
48
+ data.except(*SENSITIVITY_CONTENT_KEYS)
49
+ when :medium
50
+ redact_sensitive_keys(data)
51
+ else
52
+ data
53
+ end
54
+ end
55
+
56
+ def self.redact_sensitive_keys(data)
57
+ data.each_with_object({}) do |(key, value), filtered|
58
+ filtered[key] = if SENSITIVITY_CONTENT_KEYS.include?(key)
59
+ redact_value(value)
60
+ else
61
+ value
62
+ end
63
+ end
64
+ end
65
+
66
+ def self.redact_value(value)
67
+ case value
68
+ when String
69
+ "[REDACTED]"
70
+ when Hash
71
+ value.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
72
+ else
73
+ value
74
+ end
75
+ end
76
+
77
+ def self.filter_fields(type, data)
78
+ configured_fields = Smith.config.trace_fields
79
+ return data unless configured_fields.is_a?(Hash)
80
+
81
+ allowed = configured_fields[type]
82
+ return data unless allowed.respond_to?(:include?)
83
+
84
+ data.each_with_object({}) do |(key, value), filtered|
85
+ filtered[key] = value if allowed.include?(key)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-types"
4
+
5
+ module Smith
6
+ module Types
7
+ include Dry.Types()
8
+
9
+ # Dry.Types() resolves constants via const_missing, which means
10
+ # const_defined?(:String, false) returns false. Contract specs use
11
+ # strict const_defined?(name, false) lookups, so we materialize
12
+ # the constants we need as direct module constants.
13
+ const_set(:String, const_get(:String))
14
+ const_set(:Integer, const_get(:Integer))
15
+ end
16
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ VERSION = "0.4.0"
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module ArtifactIntegration
8
+ private
9
+
10
+ def with_scoped_artifacts
11
+ if @inherited_scoped_artifacts
12
+ Smith.scoped_artifacts = @inherited_scoped_artifacts
13
+ else
14
+ backend = Smith.config.artifact_store || Smith.artifacts
15
+ Smith.scoped_artifacts = Artifacts::ScopedStore.new(backend: backend, namespace: execution_namespace)
16
+ end
17
+ yield
18
+ end
19
+
20
+ def execution_namespace
21
+ @execution_namespace ||= SecureRandom.uuid
22
+ end
23
+
24
+ def propagate_scoped_artifacts
25
+ Smith.scoped_artifacts
26
+ end
27
+
28
+ def build_session
29
+ manager = self.class.context_manager
30
+ messages = @session_messages ||= []
31
+ return nil if manager.nil? && messages.empty?
32
+
33
+ Context::Session.new(
34
+ messages: messages,
35
+ context_manager: manager,
36
+ persisted_context: @context
37
+ )
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module BudgetIntegration
6
+ TOKEN_DIMENSIONS = %i[total_tokens token_limit].freeze
7
+ COST_DIMENSIONS = %i[total_cost].freeze
8
+ BUDGET_DIMENSIONS = (TOKEN_DIMENSIONS + COST_DIMENSIONS).freeze
9
+ AGENT_DIM_MAP = { token_limit: TOKEN_DIMENSIONS, cost: COST_DIMENSIONS }.freeze
10
+
11
+ private
12
+
13
+ def reserve_branch_budget(ledger, branch_estimates:)
14
+ return nil unless ledger && branch_estimates
15
+
16
+ branch_estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
17
+ branch_estimates
18
+ end
19
+
20
+ def compute_branch_estimates(ledger, branch_count:, agent_budget: nil)
21
+ return nil unless ledger
22
+
23
+ ledger.limits.each_with_object({}) do |(dim, _limit), est|
24
+ per_branch = estimate_for_dimension(dim, ledger.remaining(dim), branch_count)
25
+ cap = agent_cap_for_dimension(dim, agent_budget)
26
+ est[dim] = cap ? [per_branch, cap].min : per_branch
27
+ end
28
+ end
29
+
30
+ def reconcile_branch_budget(ledger, estimates, agent_result: nil)
31
+ return unless ledger && estimates
32
+
33
+ actuals = extract_actuals(agent_result)
34
+ estimates.each do |dim, amt|
35
+ ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
36
+ end
37
+ end
38
+
39
+ def extract_actuals(agent_result)
40
+ {
41
+ tokens: (agent_result&.input_tokens || 0) + (agent_result&.output_tokens || 0),
42
+ cost: agent_result&.cost || 0
43
+ }
44
+ end
45
+
46
+ def actual_for_dimension(dim, actual_tokens, actual_cost = 0)
47
+ return actual_tokens if TOKEN_DIMENSIONS.include?(dim)
48
+ return actual_cost if COST_DIMENSIONS.include?(dim)
49
+
50
+ 0
51
+ end
52
+
53
+ def release_branch_budget(ledger, estimates)
54
+ return unless ledger && estimates
55
+
56
+ estimates.each { |dim, amount| ledger.release!(dim, amount) }
57
+ end
58
+
59
+ def settle_budget_on_failure(ledger, estimates, agent_result)
60
+ return unless ledger && estimates
61
+
62
+ if agent_result
63
+ reconcile_branch_budget(ledger, estimates, agent_result: agent_result)
64
+ else
65
+ release_branch_budget(ledger, estimates)
66
+ end
67
+ end
68
+
69
+ def reserve_serial_budget(ledger, agent_budget: nil)
70
+ return nil unless ledger
71
+
72
+ estimates = ledger.limits.each_with_object({}) do |(dim, _limit), est|
73
+ remaining = BUDGET_DIMENSIONS.include?(dim) ? ledger.remaining(dim) : 0
74
+ cap = agent_cap_for_dimension(dim, agent_budget)
75
+ est[dim] = cap ? [remaining, cap].min : remaining
76
+ end
77
+
78
+ estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
79
+ estimates
80
+ end
81
+
82
+ def finalize_branch(transition, index, result, ledger, reserved)
83
+ agent_result = result.is_a?(Workflow::AgentResult) ? result : nil
84
+ reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
85
+ { branch: index, agent: transition.agent_name, output: agent_result ? agent_result.content : result }
86
+ end
87
+
88
+ def estimate_for_dimension(dim, limit, branch_count)
89
+ return 0 unless BUDGET_DIMENSIONS.include?(dim)
90
+
91
+ TOKEN_DIMENSIONS.include?(dim) ? [limit / branch_count, 1].max : limit / branch_count
92
+ end
93
+
94
+ def agent_cap_for_dimension(dim, agent_budget)
95
+ return nil unless agent_budget
96
+ return agent_budget[dim] if agent_budget.key?(dim)
97
+
98
+ AGENT_DIM_MAP.each do |agent_dim, workflow_dims|
99
+ return agent_budget[agent_dim] if workflow_dims.include?(dim) && agent_budget.key?(agent_dim)
100
+ end
101
+ nil
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # ActiveRecord-aware atomic claim helper. Two strategies:
6
+ #
7
+ # .atomic — AASM-event path. SELECT FOR UPDATE + record.public_send(transition_via)
8
+ # inside transaction_owner.transaction. AASM callbacks fire.
9
+ # .cas — single-statement CAS via update_all + where(status: from_statuses).
10
+ # Does NOT invoke AASM events; intended for non-AASM claim sites
11
+ # that already use update_all today.
12
+ #
13
+ # ActiveRecord is loaded lazily — this file does NOT const-reference
14
+ # ::ActiveRecord at module load. Both methods raise AdapterUnavailable
15
+ # before any other work when ::ActiveRecord is not defined.
16
+ module Claim
17
+ class AdapterUnavailable < Smith::Error; end
18
+ class UnexpectedStatus < Smith::Error
19
+ attr_reader :model, :id, :observed_status
20
+
21
+ def initialize(model:, id:, observed_status:)
22
+ @model = model
23
+ @id = id
24
+ @observed_status = observed_status
25
+ super("unexpected status #{observed_status.inspect} for #{model.name}##{id}")
26
+ end
27
+ end
28
+
29
+ def self.atomic(model_class, id:, from_statuses:, transition_via:,
30
+ terminal_statuses: [], on_unexpected_status: :raise,
31
+ transaction_owner: nil)
32
+ ensure_active_record!
33
+ validate_atomic_args!(model_class, transition_via)
34
+
35
+ owner = transaction_owner || model_class
36
+ from = Array(from_statuses).map(&:to_s)
37
+ terminal = Array(terminal_statuses).map(&:to_s)
38
+
39
+ claimed = false
40
+
41
+ owner.transaction do
42
+ locked = model_class.lock.find(id)
43
+ status = locked.public_send(:status).to_s
44
+
45
+ if from.include?(status)
46
+ locked.public_send(transition_via)
47
+ claimed = true
48
+ elsif terminal.include?(status)
49
+ claimed = false
50
+ else
51
+ handle_unexpected_status!(model_class, id, status, on_unexpected_status)
52
+ claimed = false
53
+ end
54
+ end
55
+
56
+ claimed ? model_class.find(id) : nil
57
+ end
58
+
59
+ def self.cas(model_class, id:, from_statuses:, to_status:,
60
+ status_column: :status, updated_at_column: :updated_at,
61
+ now: -> { Time.now.utc })
62
+ ensure_active_record!
63
+ from = Array(from_statuses).map(&:to_s)
64
+
65
+ updates = { status_column => to_status.to_s }
66
+ updates[updated_at_column] = now.call if updated_at_column
67
+
68
+ rows = model_class
69
+ .where(:id => id, status_column => from)
70
+ .update_all(updates)
71
+
72
+ rows.zero? ? nil : model_class.find(id)
73
+ end
74
+
75
+ def self.ensure_active_record!
76
+ return if defined?(::ActiveRecord::Base)
77
+
78
+ raise AdapterUnavailable,
79
+ "Smith::Workflow::Claim requires ActiveRecord (::ActiveRecord::Base is not defined). " \
80
+ "Add activerecord to your bundle. See docs/workflow_claim.md."
81
+ end
82
+ private_class_method :ensure_active_record!
83
+
84
+ def self.validate_atomic_args!(model_class, transition_via)
85
+ if transition_via.nil?
86
+ if model_class.respond_to?(:aasm)
87
+ raise ArgumentError,
88
+ "Smith::Workflow::Claim.atomic requires transition_via: when the model uses AASM " \
89
+ "(#{model_class.name} responds to .aasm). Use .cas for non-AASM CAS claims."
90
+ end
91
+ raise ArgumentError, "Smith::Workflow::Claim.atomic requires transition_via: (Symbol naming the event method)"
92
+ end
93
+
94
+ return if transition_via.is_a?(Symbol) || transition_via.is_a?(String)
95
+
96
+ raise ArgumentError, "transition_via must be a Symbol or String; got #{transition_via.inspect}"
97
+ end
98
+ private_class_method :validate_atomic_args!
99
+
100
+ def self.handle_unexpected_status!(model_class, id, status, mode)
101
+ case mode
102
+ when :raise
103
+ raise UnexpectedStatus.new(model: model_class, id: id, observed_status: status)
104
+ when :ignore
105
+ nil
106
+ when :log
107
+ Smith.config.logger&.warn(
108
+ "Smith::Workflow::Claim.atomic: unexpected status #{status.inspect} for #{model_class.name}##{id}"
109
+ )
110
+ nil
111
+ else
112
+ raise ArgumentError, "on_unexpected_status must be :raise, :ignore, or :log; got #{mode.inspect}"
113
+ end
114
+ end
115
+ private_class_method :handle_unexpected_status!
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module DataVolumePolicy
6
+ LIGHTWEIGHT_SCALARS = [String, Integer, Float, Symbol, TrueClass, FalseClass, NilClass].freeze
7
+
8
+ private
9
+
10
+ def validate_data_volume!(output, agent_class)
11
+ return unless agent_class.respond_to?(:data_volume)
12
+ return unless agent_class.data_volume == :unbounded
13
+ return unless output.is_a?(Hash)
14
+
15
+ require_ref_key!(output)
16
+ require_scalar_values!(output)
17
+ end
18
+
19
+ def require_ref_key!(output)
20
+ return if output.keys.any? { |k| k.to_s.end_with?("_ref") }
21
+
22
+ raise GuardrailFailed, "data_volume :unbounded requires at least one *_ref key"
23
+ end
24
+
25
+ def require_scalar_values!(output)
26
+ output.each do |key, value|
27
+ next if key.to_s.end_with?("_ref")
28
+ next if LIGHTWEIGHT_SCALARS.any? { |type| value.is_a?(type) }
29
+
30
+ raise GuardrailFailed,
31
+ "data_volume :unbounded requires lightweight scalar values, got #{value.class} for :#{key}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module DeadlineEnforcement
8
+ private
9
+
10
+ def check_deadline!
11
+ deadline = effective_deadline
12
+ return unless deadline
13
+
14
+ raise DeadlineExceeded, "wall_clock deadline exceeded" if Time.now.utc >= deadline
15
+ end
16
+
17
+ def effective_deadline
18
+ call_dl = Thread.current[:smith_call_deadline]
19
+ [wall_clock_deadline, call_dl].compact.min
20
+ end
21
+
22
+ def with_agent_context(agent_class)
23
+ saved_deadline = Tool.current_deadline
24
+ saved_call_ledger = Thread.current[:smith_call_ledger]
25
+ apply_agent_deadline(agent_class)
26
+ narrow_tool_deadline!
27
+ apply_agent_tool_calls(agent_class)
28
+ apply_agent_call_ledger(agent_class)
29
+ yield
30
+ ensure
31
+ Tool.current_deadline = saved_deadline
32
+ Thread.current[:smith_call_ledger] = saved_call_ledger
33
+ clear_agent_deadline
34
+ clear_agent_tool_calls
35
+ end
36
+
37
+ def effective_call_ledger
38
+ @ledger || Thread.current[:smith_call_ledger]
39
+ end
40
+
41
+ def apply_agent_deadline(agent_class)
42
+ agent_wc = agent_class&.budget&.dig(:wall_clock)
43
+ Thread.current[:smith_call_deadline] = agent_wc ? Time.now.utc + agent_wc : nil
44
+ end
45
+
46
+ def clear_agent_deadline
47
+ Thread.current[:smith_call_deadline] = nil
48
+ end
49
+
50
+ def narrow_tool_deadline!
51
+ call_dl = Thread.current[:smith_call_deadline]
52
+ return unless call_dl
53
+
54
+ current = Tool.current_deadline
55
+ Tool.current_deadline = current ? [current, call_dl].min : call_dl
56
+ end
57
+
58
+ def apply_agent_tool_calls(agent_class)
59
+ agent_tc = agent_class&.budget&.dig(:tool_calls)
60
+ Tool.current_tool_call_allowance = agent_tc ? { remaining: agent_tc } : nil
61
+ end
62
+
63
+ def clear_agent_tool_calls
64
+ Tool.current_tool_call_allowance = nil
65
+ end
66
+
67
+ def apply_agent_call_ledger(agent_class)
68
+ Thread.current[:smith_call_ledger] = @ledger ? nil : build_agent_call_ledger(agent_class)
69
+ end
70
+
71
+ def build_agent_call_ledger(agent_class)
72
+ agent_budget = agent_class&.budget
73
+ return nil unless agent_budget
74
+
75
+ limits = {}
76
+ limits[:token_limit] = agent_budget[:token_limit] if agent_budget[:token_limit]
77
+ limits[:total_cost] = agent_budget[:cost] if agent_budget[:cost]
78
+ return nil if limits.empty?
79
+
80
+ Budget::Ledger.new(limits: limits)
81
+ end
82
+
83
+ def wall_clock_deadline
84
+ return @wall_clock_deadline if defined?(@wall_clock_deadline)
85
+
86
+ @wall_clock_deadline = compute_wall_clock_deadline
87
+ end
88
+
89
+ def compute_wall_clock_deadline
90
+ limit = self.class.budget&.dig(:wall_clock)
91
+ own_deadline = limit ? Time.iso8601(@created_at) + limit : nil
92
+
93
+ return own_deadline unless @inherited_deadline
94
+ return @inherited_deadline unless own_deadline
95
+
96
+ [own_deadline, @inherited_deadline].min
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module DeterministicExecution
6
+ private
7
+
8
+ def execute_deterministic_step(transition)
9
+ check_deadline!
10
+ step = build_deterministic_step(transition)
11
+ emit_deterministic_trace(transition, result: :started)
12
+ transition.deterministic_block.call(step)
13
+ apply_deterministic_writes!(step)
14
+ emit_deterministic_trace(
15
+ transition,
16
+ result: step.routed_to ? :routed : :success,
17
+ routed_to: step.routed_to,
18
+ outcome_kind: step.outcome&.dig(:kind)
19
+ )
20
+ nil
21
+ rescue StandardError => e
22
+ emit_deterministic_trace(transition, result: :failed, error: e.message)
23
+ raise
24
+ end
25
+
26
+ def build_deterministic_step(transition)
27
+ DeterministicStep.new(
28
+ context: snapshot_value(@context),
29
+ session_messages: snapshot_value(@session_messages || []),
30
+ tool_results: snapshot_value(@tool_results || []),
31
+ state: @state,
32
+ transition_name: transition.name
33
+ )
34
+ end
35
+
36
+ def apply_deterministic_writes!(step)
37
+ @context.merge!(step.context_writes)
38
+ step.context_writes.each_key { |key| record_persisted_key!(key) }
39
+ @router_next_transition = step.routed_to if step.routed_to
40
+ @outcome = snapshot_value(step.outcome) if step.outcome
41
+ end
42
+
43
+ def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
44
+ data = { transition: transition.name, from: transition.from, to: transition.to,
45
+ kind: transition.deterministic_kind, result: result }
46
+ data[:routed_to] = routed_to if routed_to
47
+ data[:error] = error if error
48
+ data[:outcome_kind] = outcome_kind if outcome_kind
49
+ Smith::Trace.record(type: :deterministic_step, data: data)
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class DeterministicStep
6
+ attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
7
+ :context_writes, :routed_to, :outcome
8
+
9
+ def initialize(context:, session_messages:, tool_results:, state:, transition_name:)
10
+ @context = context
11
+ @session_messages = session_messages
12
+ @tool_results = tool_results
13
+ @current_state = state
14
+ @transition_name = transition_name
15
+ @context_writes = {}
16
+ @routed_to = nil
17
+ @outcome = nil
18
+ end
19
+
20
+ def last_output
21
+ return @last_output if defined?(@last_output)
22
+
23
+ msg = session_messages.reverse.find { |m| m[:role] == :assistant || m[:role] == "assistant" }
24
+ @last_output = msg&.dig(:content)
25
+ end
26
+
27
+ alias_method :output, :last_output
28
+
29
+ def read_context(key)
30
+ @context_writes.key?(key) ? @context_writes[key] : context[key]
31
+ end
32
+
33
+ def write_context(key, value)
34
+ raise WorkflowError, "write_context key must be a Symbol, got #{key.class}" unless key.is_a?(Symbol)
35
+
36
+ @context_writes[key] = value
37
+ end
38
+
39
+ def route_to(transition_name)
40
+ raise WorkflowError, "route_to already called with :#{@routed_to}" if @routed_to
41
+
42
+ @routed_to = transition_name
43
+ end
44
+
45
+ def write_outcome(kind:, payload:)
46
+ raise WorkflowError, "write_outcome kind must be a Symbol, got #{kind.class}" unless kind.is_a?(Symbol)
47
+ raise WorkflowError, "write_outcome already called with :#{@outcome[:kind]}" if @outcome
48
+
49
+ @outcome = { kind: kind, payload: payload }
50
+ end
51
+
52
+ def fail!(message, retryable: nil, kind: nil, details: nil)
53
+ raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
54
+ end
55
+ end
56
+ end
57
+ end