smith-agents 0.6.1 → 0.8.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 (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -1
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +293 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +31 -177
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +17 -11
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +134 -0
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/capture.rb +60 -4
  60. data/lib/smith/tool/capture_configuration.rb +20 -0
  61. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  62. data/lib/smith/tool/chat_execution_context.rb +24 -0
  63. data/lib/smith/tool/execution_authority.rb +42 -0
  64. data/lib/smith/tool/execution_authorization.rb +17 -0
  65. data/lib/smith/tool/execution_batch.rb +100 -0
  66. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  67. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  68. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  69. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  70. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  71. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  72. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  73. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  74. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  75. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  76. data/lib/smith/tool/execution_batch_state.rb +110 -0
  77. data/lib/smith/tool/execution_dispatch.rb +78 -0
  78. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  79. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  80. data/lib/smith/tool/execution_tracker.rb +20 -0
  81. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  82. data/lib/smith/tool/graceful_completion.rb +20 -0
  83. data/lib/smith/tool/invocation.rb +27 -0
  84. data/lib/smith/tool/invocation_request.rb +37 -0
  85. data/lib/smith/tool/invocation_sequence.rb +26 -0
  86. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  87. data/lib/smith/tool/scoped_context.rb +123 -0
  88. data/lib/smith/tool.rb +78 -57
  89. data/lib/smith/tool_capture_failed.rb +106 -0
  90. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  91. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  92. data/lib/smith/version.rb +2 -2
  93. data/lib/smith/workflow/agent_result.rb +4 -3
  94. data/lib/smith/workflow/artifact_integration.rb +2 -1
  95. data/lib/smith/workflow/composite/branch_failure.rb +34 -13
  96. data/lib/smith/workflow/composite/branch_outcome.rb +19 -2
  97. data/lib/smith/workflow/composite/effects.rb +3 -1
  98. data/lib/smith/workflow/composite/error.rb +53 -5
  99. data/lib/smith/workflow/composite/error_evidence.rb +10 -2
  100. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  101. data/lib/smith/workflow/execution.rb +2 -9
  102. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  103. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  104. data/lib/smith/workflow/failure_record.rb +139 -0
  105. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  106. data/lib/smith/workflow/failure_record_text.rb +83 -0
  107. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  108. data/lib/smith/workflow/fanout_execution.rb +12 -11
  109. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  110. data/lib/smith/workflow/parallel/cancellation_signal.rb +1 -1
  111. data/lib/smith/workflow/parallel/root_execution.rb +1 -1
  112. data/lib/smith/workflow/parallel.rb +17 -1
  113. data/lib/smith/workflow/persistence.rb +22 -19
  114. data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
  115. data/lib/smith/workflow/retry_execution.rb +5 -0
  116. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
  117. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  118. data/lib/smith/workflow/transition.rb +11 -0
  119. data/lib/smith/workflow/usage_entry.rb +12 -2
  120. data/lib/smith/workflow.rb +17 -124
  121. data/lib/smith.rb +3 -0
  122. metadata +83 -5
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../errors"
6
+ require_relative "composite/branch_failure"
7
+
8
+ module Smith
9
+ class Workflow
10
+ class FailureRecordValidator
11
+ KNOWN_ERROR_FAMILIES = {
12
+ "Smith::DeterministicStepFailure" => "deterministic_step_failure",
13
+ "Smith::ToolGuardrailFailed" => "tool_guardrail_failed",
14
+ "Smith::ToolCaptureFailed" => "tool_capture_failed",
15
+ "Smith::ToolFailureNotificationFailed" => "tool_failure_notification_failed",
16
+ "Smith::ToolOutcomeUncertain" => "tool_outcome_uncertain",
17
+ "Smith::ToolExecutionNotAdmitted" => "tool_execution_not_admitted",
18
+ "Smith::BoundedCompletionError" => "bounded_completion_error",
19
+ "Smith::PersistedFailureInvalid" => "persisted_failure_invalid",
20
+ "Smith::AgentError" => "agent_error",
21
+ "Smith::BlankAgentOutputError" => "agent_error",
22
+ "Smith::DeadlineExceeded" => "deadline_exceeded",
23
+ "Smith::WorkflowError" => "workflow_error",
24
+ "Smith::UnresolvedTransitionError" => "workflow_error",
25
+ "Smith::Workflow::Composite::BranchFailure" => "workflow_error"
26
+ }.freeze
27
+ KNOWN_FAMILIES = %w[
28
+ deterministic_step_failure tool_guardrail_failed tool_failure_notification_failed tool_capture_failed
29
+ tool_outcome_uncertain tool_execution_not_admitted bounded_completion_error persisted_failure_invalid
30
+ deadline_exceeded agent_error workflow_error other
31
+ ].freeze
32
+ RETRY_FORBIDDEN_FAMILIES = %w[
33
+ tool_capture_failed tool_failure_notification_failed tool_outcome_uncertain tool_execution_not_admitted
34
+ bounded_completion_error persisted_failure_invalid
35
+ ].freeze
36
+ DETAIL_VALIDATORS = {
37
+ "Smith::ToolCaptureFailed" => ->(details) { Smith::ToolCaptureFailed.from_details(details) },
38
+ "Smith::ToolFailureNotificationFailed" => lambda { |details|
39
+ Smith::ToolFailureNotificationFailed.from_details(details)
40
+ },
41
+ "Smith::Workflow::Composite::BranchFailure" => lambda { |details|
42
+ Smith::Workflow::Composite::BranchFailure.from_details(details)
43
+ }
44
+ }.freeze
45
+ BOOLEAN_VALUES = [true, false].freeze
46
+ private_constant :KNOWN_ERROR_FAMILIES, :KNOWN_FAMILIES, :RETRY_FORBIDDEN_FAMILIES, :DETAIL_VALIDATORS,
47
+ :BOOLEAN_VALUES
48
+
49
+ extend Dry::Initializer
50
+
51
+ param :snapshot
52
+
53
+ def call
54
+ validate_family!
55
+ validate_class_family!
56
+ validate_retry_policy!
57
+ validate_cause!
58
+ validate_details!
59
+ snapshot
60
+ rescue ArgumentError, KeyError, TypeError
61
+ reject!("persisted workflow failure details are invalid")
62
+ end
63
+
64
+ private
65
+
66
+ def family = snapshot[:error_family]
67
+
68
+ def validate_family!
69
+ return if KNOWN_FAMILIES.include?(family)
70
+
71
+ reject!("persisted workflow failure family is invalid")
72
+ end
73
+
74
+ def validate_class_family!
75
+ expected = KNOWN_ERROR_FAMILIES[snapshot[:error_class]]
76
+ return unless expected && family != expected
77
+
78
+ reject!("persisted workflow failure class and family disagree")
79
+ end
80
+
81
+ def validate_retry_policy!
82
+ forbidden = snapshot[:error_retry_forbidden]
83
+ return if forbidden.nil?
84
+
85
+ reject!("persisted workflow failure retry policy is invalid") unless BOOLEAN_VALUES.include?(forbidden)
86
+ return if forbidden == RETRY_FORBIDDEN_FAMILIES.include?(family)
87
+
88
+ reject!("persisted workflow failure retry policy disagrees with its family")
89
+ end
90
+
91
+ # Cause classification travels as one unit: capture writes all three
92
+ # attributes together for uncertainty wrappers, so a partial set is
93
+ # corrupt data rather than a legacy shape (legacy records omit all
94
+ # three).
95
+ def validate_cause!
96
+ cause_values = snapshot.values_at(:error_cause_class, :error_cause_family, :error_cause_message)
97
+ return if cause_values.all?(&:nil?)
98
+
99
+ reject!("persisted workflow failure cause is incomplete") if cause_values.any?(&:nil?)
100
+ return if KNOWN_FAMILIES.include?(snapshot[:error_cause_family])
101
+
102
+ reject!("persisted workflow failure cause family is invalid")
103
+ end
104
+
105
+ def validate_details!
106
+ validator = DETAIL_VALIDATORS[snapshot[:error_class]]
107
+ validator&.call(snapshot[:error_details])
108
+ end
109
+
110
+ def reject!(message) = raise(Smith::PersistedFailureInvalid, message)
111
+ end
112
+ end
113
+ end
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "branch_env"
4
-
5
3
  module Smith
6
4
  class Workflow
7
5
  module FanoutExecution
8
6
  private
9
7
 
8
+ def fanout_branch_environment(branches, branch_agent_classes, prepared_input)
9
+ BranchEnv.new(
10
+ prepared_input:,
11
+ guardrail_sources: nil,
12
+ scoped_store: propagate_scoped_artifacts,
13
+ branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
14
+ deadline: wall_clock_deadline
15
+ )
16
+ end
17
+
10
18
  def run_guarded_fanout_step(transition)
11
19
  branches = transition.fanout_config.fetch(:branches)
12
20
  branch_agent_classes = fanout_agent_classes(transition, branches)
@@ -26,13 +34,7 @@ module Smith
26
34
  def execute_fanout_step(transition, branches: nil, branch_agent_classes: nil, prepared_input: nil)
27
35
  branches ||= transition.fanout_config.fetch(:branches)
28
36
  branch_agent_classes ||= fanout_agent_classes(transition, branches)
29
- env = BranchEnv.new(
30
- prepared_input: prepared_input,
31
- guardrail_sources: nil,
32
- scoped_store: propagate_scoped_artifacts,
33
- branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
34
- deadline: wall_clock_deadline
35
- )
37
+ env = fanout_branch_environment(branches, branch_agent_classes, prepared_input)
36
38
 
37
39
  branch_calls = branches.map do |branch_key, agent_name|
38
40
  PreparedBranchExecution.instance_method(:prepared_branch).bind_call(
@@ -98,12 +100,11 @@ module Smith
98
100
  def fanout_branch_estimates(branches, branch_agent_classes)
99
101
  return {} unless @ledger
100
102
 
101
- branch_count = branches.length
102
103
  branches.each_with_object({}) do |(branch_key, _agent_name), map|
103
104
  agent_class = branch_agent_classes.fetch(branch_key)
104
105
  map[branch_key] = compute_branch_estimates(
105
106
  @ledger,
106
- branch_count: branch_count,
107
+ branch_count: branches.length,
107
108
  agent_budget: agent_class&.budget
108
109
  )
109
110
  end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module GuardedStepExecution
6
+ private
7
+
8
+ def run_guarded_step(transition)
9
+ tracker = Tool::ExecutionTracker.new
10
+ previous_tracker = Tool.current_tool_execution_tracker
11
+
12
+ Thread.handle_interrupt(Object => :never) do
13
+ Tool.current_tool_execution_tracker = tracker
14
+ begin
15
+ Thread.handle_interrupt(Object => :immediate) { run_tracked_guarded_step(transition) }
16
+ rescue StandardError => e
17
+ raise unless tracker.started?
18
+ raise if terminal_retry_error?(e)
19
+
20
+ raise ToolOutcomeUncertain.new(
21
+ "transition failed after tool execution began; retry could replay an uncertain outcome"
22
+ ), cause: e
23
+ ensure
24
+ Tool.current_tool_execution_tracker = previous_tracker
25
+ end
26
+ end
27
+ end
28
+
29
+ def run_tracked_guarded_step(transition)
30
+ return apply_composite_reduction!(transition) if @composite_reduction
31
+
32
+ @resolved_parallel_branch_count = preflight_branch_count(transition)
33
+ run_standard_guarded_step(transition)
34
+ ensure
35
+ @resolved_parallel_branch_count = nil
36
+ end
37
+ end
38
+ end
39
+ end
@@ -12,7 +12,7 @@ module Smith
12
12
 
13
13
  def cancel!(error = nil)
14
14
  @mutex.synchronize do
15
- @reason ||= error
15
+ @reason = Parallel.preferred_error([@reason, error])
16
16
  @cancelled = true
17
17
  end
18
18
  end
@@ -73,7 +73,7 @@ module Smith
73
73
 
74
74
  def resolve(futures)
75
75
  fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
76
- raise(@signal.reason || Parallel.preferred_error(reasons)) unless fulfilled
76
+ raise(Parallel.preferred_error([@signal.reason, *reasons])) unless fulfilled
77
77
 
78
78
  values
79
79
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
4
+ require_relative "../tool_capture_failed"
3
5
  require_relative "parallel/cancellation"
4
6
  require_relative "parallel/cancellation_signal"
5
7
  require_relative "parallel/execution_context"
@@ -36,8 +38,22 @@ module Smith
36
38
 
37
39
  def self.preferred_error(reasons)
38
40
  errors = Array(reasons).compact
39
- errors.find { |error| !error.is_a?(Cancellation) } || errors.first
41
+ process_fatal_error(errors) ||
42
+ notification_failure(errors) ||
43
+ terminal_execution_error(errors) ||
44
+ ordinary_error(errors) ||
45
+ errors.first
40
46
  end
47
+
48
+ def self.process_fatal_error(errors) = errors.find { !_1.is_a?(StandardError) }
49
+
50
+ def self.notification_failure(errors) = errors.find { _1.is_a?(ToolFailureNotificationFailed) }
51
+
52
+ def self.terminal_execution_error(errors) = errors.find { Smith::Errors.retry_forbidden?(_1) }
53
+
54
+ def self.ordinary_error(errors) = errors.find { !_1.is_a?(Cancellation) }
55
+
56
+ private_class_method :process_fatal_error, :notification_failure, :terminal_execution_error, :ordinary_error
41
57
  end
42
58
  end
43
59
  end
@@ -48,6 +48,10 @@ module Smith
48
48
  # cycles so seed_validation can detect when the seed builder
49
49
  # has changed in code since this workflow was persisted.
50
50
  seed_digest: @seed_digest,
51
+ # Number of immutable seed messages at the beginning of session
52
+ # history. Context strategies may preserve this exact prefix while
53
+ # masking later workflow observations.
54
+ seed_message_count: @seed_message_count || 0,
51
55
  # Step-in-progress idempotency marker. Set true between
52
56
  # persist-before-advance and persist-after-advance when the
53
57
  # workflow class opts into idempotency_mode :strict. Restore
@@ -76,6 +80,7 @@ module Smith
76
80
  @ledger = rebuild_ledger(normalized[:budget_consumed] || {})
77
81
  @next_transition_name = normalized[:next_transition_name]
78
82
  @session_messages = normalized[:session_messages] || []
83
+ @seed_message_count = validated_seed_message_count(normalized)
79
84
  @total_cost = normalized[:total_cost] || 0.0
80
85
  @total_tokens = normalized[:total_tokens] || 0
81
86
  @outcome = normalized[:outcome]
@@ -120,6 +125,14 @@ module Smith
120
125
  "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
121
126
  end
122
127
 
128
+ def validated_seed_message_count(normalized)
129
+ count = normalized.fetch(:seed_message_count, 0)
130
+ return count if count.is_a?(Integer) && count >= 0 && count <= @session_messages.length
131
+
132
+ raise Smith::SerializationError,
133
+ "persisted workflow seed_message_count must be between 0 and session message count"
134
+ end
135
+
123
136
  def validate_definition_digest!(normalized)
124
137
  stored = normalized[:definition_digest]
125
138
  return if stored.nil?
@@ -200,20 +213,11 @@ module Smith
200
213
  # values become strings).
201
214
  def restore_last_failed_step(normalized)
202
215
  raw = normalized[:last_failed_step]
203
- return nil unless raw.is_a?(Hash)
204
-
205
- h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
206
- {
207
- transition: normalize_transition_name(h[:transition]),
208
- from: normalize_state_name(h[:from]),
209
- to: normalize_state_name(h[:to]),
210
- error_class: h[:error_class],
211
- error_family: h[:error_family],
212
- error_message: h[:error_message],
213
- error_retryable: h[:error_retryable],
214
- error_kind: h[:error_kind]&.to_sym,
215
- error_details: h[:error_details]
216
- }
216
+ FailureRecordRestore.new(
217
+ raw,
218
+ transition_normalizer: method(:normalize_transition_name),
219
+ state_normalizer: method(:normalize_state_name)
220
+ ).call
217
221
  end
218
222
 
219
223
  def restore_core_fields(normalized)
@@ -234,9 +238,9 @@ module Smith
234
238
  end
235
239
 
236
240
  manager = self.class.context_manager
237
- if manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
241
+ if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
238
242
  ctx = normalized[:context]
239
- existing = ctx.is_a?(Hash) ? ctx.keys.map { |k| k.to_sym } : []
243
+ existing = ctx.is_a?(Hash) ? ctx.keys.map(&:to_sym) : []
240
244
  seed = manager.persist_auto_seed.map(&:to_sym)
241
245
  @persisted_keys = ::Set.new(existing + seed)
242
246
  else
@@ -309,6 +313,7 @@ module Smith
309
313
 
310
314
  def normalize_transition_name(value)
311
315
  return if value.nil?
316
+
312
317
  transition = self.class.find_transition(value)
313
318
  return transition.name if transition
314
319
  return value unless value.is_a?(String)
@@ -416,9 +421,7 @@ module Smith
416
421
  manager = self.class.context_manager
417
422
  return nil unless manager
418
423
 
419
- if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
420
- return :auto
421
- end
424
+ return :auto if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
422
425
 
423
426
  keys = manager.persist
424
427
  keys.empty? ? nil : keys
@@ -6,13 +6,20 @@ module Smith
6
6
  private
7
7
 
8
8
  def prepared_branch(implementation, *arguments)
9
+ tool_context = Tool::ScopedContext.capture
9
10
  unless @split_step_active_execution_authorization
10
- return proc { |signal| __send__(implementation.name, *arguments, signal) }
11
+ return proc do |signal|
12
+ Tool::ScopedContext.around(tool_context) do
13
+ __send__(implementation.name, *arguments, signal)
14
+ end
15
+ end
11
16
  end
12
17
 
13
18
  proc do |signal|
14
19
  run = proc { implementation.bind_call(self, *arguments, signal) }
15
- PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
20
+ Tool::ScopedContext.around(tool_context) do
21
+ PreparedBranchExecution.instance_method(:within_prepared_branch_execution).bind_call(self, &run)
22
+ end
16
23
  end
17
24
  end
18
25
 
@@ -24,6 +24,7 @@ module Smith
24
24
 
25
25
  def retry_transition_error?(config, error, attempt)
26
26
  return false if attempt >= config.fetch(:attempts)
27
+ return false if terminal_retry_error?(error)
27
28
 
28
29
  classes = config.fetch(:error_classes)
29
30
  if classes.any?
@@ -33,6 +34,10 @@ module Smith
33
34
  end
34
35
  end
35
36
 
37
+ def terminal_retry_error?(error)
38
+ Smith::Errors.retry_forbidden?(error)
39
+ end
40
+
36
41
  def sleep_for_retry(schedule, failed_attempt)
37
42
  delay = retry_delay(schedule, failed_attempt)
38
43
  sleep(delay) if delay.positive?
@@ -68,18 +68,27 @@ module Smith
68
68
  def composite_branch_environment(execution, input, agent_class, transition)
69
69
  branch = execution.branch
70
70
  budget = branch.budget.transform_keys(&:to_sym)
71
- branch_key = fetch_composite_fanout_branch(transition, branch.key).first unless execution.kind == :parallel
72
- estimates = execution.kind == :parallel ? budget : { branch_key => budget }
73
71
  BranchEnv.new(
74
72
  prepared_input: input.agent_messages,
75
73
  guardrail_sources: Tool.current_guardrails,
76
74
  scoped_store: propagate_scoped_artifacts,
77
- branch_estimates: estimates,
75
+ branch_estimates: composite_branch_estimates(execution, transition, budget),
78
76
  deadline: wall_clock_deadline,
79
- agent_class: execution.kind == :parallel ? agent_class : nil
77
+ agent_class: composite_parallel_agent(execution, agent_class)
80
78
  )
81
79
  end
82
80
 
81
+ def composite_branch_estimates(execution, transition, budget)
82
+ return budget if execution.kind == :parallel
83
+
84
+ branch_key = fetch_composite_fanout_branch(transition, execution.branch.key).first
85
+ { branch_key => budget }
86
+ end
87
+
88
+ def composite_parallel_agent(execution, agent_class)
89
+ agent_class if execution.kind == :parallel
90
+ end
91
+
83
92
  def composite_branch_ledger(branch)
84
93
  return if branch.budget.empty?
85
94
 
@@ -15,7 +15,11 @@ module Smith
15
15
  current_deadline
16
16
  current_ledger
17
17
  current_tool_call_allowance
18
+ current_tool_execution_tracker
18
19
  current_tool_result_collector
20
+ current_invocation_context
21
+ current_invocation_sequence
22
+ current_invocation
19
23
  ].freeze
20
24
  THREAD_KEYS = %i[
21
25
  smith_call_deadline
@@ -67,14 +71,14 @@ module Smith
67
71
  def restore_context!
68
72
  raise WorkflowError, "thread context snapshot has already been restored" if @restored
69
73
 
70
- @tool_values.each { |attribute, value| Tool.public_send("#{attribute}=", value) }
74
+ @tool_values.each { |attribute, value| Tool.send("#{attribute}=", value) }
71
75
  @thread_values.each { |key, value| Thread.current[key] = value }
72
76
  Smith.scoped_artifacts = @artifact_store if scoped_artifacts
73
77
  @restored = true
74
78
  end
75
79
 
76
80
  def capture_tool_values
77
- tool_attributes.to_h { |attribute| [attribute, Tool.public_send(attribute)] }.freeze
81
+ tool_attributes.to_h { |attribute| [attribute, Tool.send(attribute)] }.freeze
78
82
  end
79
83
 
80
84
  def capture_thread_values
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../tool_capture_failed"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  class Transition
@@ -334,6 +336,7 @@ module Smith
334
336
 
335
337
  raise WorkflowError, "retry_on error classes must inherit from StandardError"
336
338
  end
339
+ reject_retry_forbidden!(error_classes)
337
340
 
338
341
  ExponentialBackoff.new(
339
342
  attempts:,
@@ -346,6 +349,14 @@ module Smith
346
349
  raise WorkflowError, "retry_on #{e.message}"
347
350
  end
348
351
 
352
+ def reject_retry_forbidden!(error_classes)
353
+ forbidden = error_classes.find { |error_class| Smith::Errors.retry_forbidden_class?(error_class) }
354
+ return unless forbidden
355
+
356
+ label = forbidden.name || "an anonymous terminal tool-evidence error class"
357
+ raise WorkflowError, "retry_on cannot retry #{label} because the tool outcome may be uncertain"
358
+ end
359
+
349
360
  def normalize_deterministic_routes!(routes)
350
361
  return nil if routes.nil?
351
362
  raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
@@ -10,6 +10,7 @@ module Smith
10
10
  :usage_id,
11
11
  :agent_name,
12
12
  :model,
13
+ :provider,
13
14
  :input_tokens,
14
15
  :output_tokens,
15
16
  :cost,
@@ -17,11 +18,20 @@ module Smith
17
18
  :recorded_at,
18
19
  keyword_init: true
19
20
  ) do
21
+ def initialize(**attributes)
22
+ attributes = attributes.transform_values do |value|
23
+ value.is_a?(String) ? value.dup.freeze : value
24
+ end
25
+ super(**attributes) # rubocop:disable Style/SuperArguments
26
+ freeze
27
+ end
28
+
20
29
  def self.from_h(hash)
21
30
  sym = hash.transform_keys(&:to_sym)
22
31
  filtered = sym.slice(*members)
23
- filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
24
- filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
32
+ %i[agent_name provider attempt_kind].each do |attribute|
33
+ filtered[attribute] = filtered[attribute].to_sym if filtered[attribute].is_a?(String)
34
+ end
25
35
  new(**filtered)
26
36
  end
27
37
  end