smith-agents 0.4.1 → 0.4.3

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -3
  3. data/README.md +69 -3
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +85 -6
  6. data/lib/smith/agent/lifecycle.rb +11 -7
  7. data/lib/smith/agent/registry/introspection.rb +27 -0
  8. data/lib/smith/agent/registry.rb +22 -17
  9. data/lib/smith/agent/registry_binding.rb +43 -0
  10. data/lib/smith/agent.rb +3 -4
  11. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  12. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  13. data/lib/smith/version.rb +1 -1
  14. data/lib/smith/workflow/agent_result.rb +26 -0
  15. data/lib/smith/workflow/branch_env.rb +24 -0
  16. data/lib/smith/workflow/budget_integration.rb +27 -5
  17. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  18. data/lib/smith/workflow/deterministic_step.rb +32 -4
  19. data/lib/smith/workflow/durability.rb +38 -12
  20. data/lib/smith/workflow/evaluator_optimizer.rb +18 -10
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +8 -1
  23. data/lib/smith/workflow/fanout_execution.rb +121 -0
  24. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  25. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  26. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  27. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  28. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  30. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  31. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  33. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  34. data/lib/smith/workflow/graph/targets.rb +5 -0
  35. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  36. data/lib/smith/workflow/graph/transition_snapshot.rb +80 -18
  37. data/lib/smith/workflow/graph.rb +16 -1
  38. data/lib/smith/workflow/graph_dsl.rb +4 -0
  39. data/lib/smith/workflow/guardrail_integration.rb +45 -10
  40. data/lib/smith/workflow/nested_execution.rb +5 -4
  41. data/lib/smith/workflow/optimization_state.rb +13 -0
  42. data/lib/smith/workflow/orchestration_state.rb +13 -0
  43. data/lib/smith/workflow/orchestrator_worker.rb +5 -16
  44. data/lib/smith/workflow/parallel/cancellation.rb +9 -0
  45. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  46. data/lib/smith/workflow/parallel.rb +12 -16
  47. data/lib/smith/workflow/parallel_execution.rb +5 -1
  48. data/lib/smith/workflow/persistence.rb +37 -6
  49. data/lib/smith/workflow/retry_execution.rb +52 -0
  50. data/lib/smith/workflow/router.rb +15 -4
  51. data/lib/smith/workflow/run_result.rb +54 -0
  52. data/lib/smith/workflow/transition.rb +260 -24
  53. data/lib/smith/workflow/usage_entry.rb +30 -0
  54. data/lib/smith/workflow/worker_execution.rb +13 -0
  55. data/lib/smith/workflow.rb +40 -136
  56. data/lib/smith.rb +12 -0
  57. metadata +24 -1
@@ -6,34 +6,69 @@ module Smith
6
6
  private
7
7
 
8
8
  def apply_tool_guardrails(agent_class)
9
- sources = [self.class.guardrails, agent_class&.guardrails].compact
9
+ sources = tool_guardrail_sources(agent_class)
10
10
  Tool.current_guardrails = sources.empty? ? nil : sources
11
11
  end
12
12
 
13
13
  def run_input_guardrails(agent_class)
14
+ run_workflow_input_guardrails
15
+ run_agent_input_guardrails(agent_class)
16
+ end
17
+
18
+ def run_output_guardrails(output, agent_class)
19
+ run_workflow_output_guardrails(output)
20
+ run_agent_output_guardrails(output, agent_class)
21
+ end
22
+
23
+ def handle_step_failure(transition, error)
24
+ failure_name = transition.failure_transition
25
+ raise error unless failure_name
26
+
27
+ fail_transition = self.class.find_transition(failure_name)
28
+ raise error unless fail_transition
29
+ validate_transition_origin!(fail_transition)
30
+
31
+ if actionable_failure_transition?(fail_transition)
32
+ @next_transition_name = failure_name
33
+ return
34
+ end
35
+
36
+ @state = fail_transition.to
37
+ end
38
+
39
+ def actionable_failure_transition?(transition)
40
+ transition.agent_name ||
41
+ transition.deterministic? ||
42
+ transition.routed? ||
43
+ transition.fanout? ||
44
+ transition.nested? ||
45
+ transition.optimized? ||
46
+ transition.orchestrated? ||
47
+ transition.success_transition
48
+ end
49
+
50
+ def run_workflow_input_guardrails
14
51
  wf_guardrails = self.class.guardrails
15
52
  Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
53
+ end
16
54
 
55
+ def run_agent_input_guardrails(agent_class)
17
56
  agent_guardrails = agent_class&.guardrails
18
57
  Guardrails::Runner.run_inputs(agent_guardrails, @context) if agent_guardrails
19
58
  end
20
59
 
21
- def run_output_guardrails(output, agent_class)
60
+ def run_workflow_output_guardrails(output)
22
61
  wf_guardrails = self.class.guardrails
23
62
  Guardrails::Runner.run_outputs(wf_guardrails, output) if wf_guardrails
63
+ end
24
64
 
65
+ def run_agent_output_guardrails(output, agent_class)
25
66
  agent_guardrails = agent_class&.guardrails
26
67
  Guardrails::Runner.run_outputs(agent_guardrails, output) if agent_guardrails
27
68
  end
28
69
 
29
- def handle_step_failure(transition, _error)
30
- failure_name = transition.failure_transition
31
- return unless failure_name
32
-
33
- fail_transition = self.class.find_transition(failure_name)
34
- return unless fail_transition
35
-
36
- @state = fail_transition.to
70
+ def tool_guardrail_sources(agent_class)
71
+ [self.class.guardrails, agent_class&.guardrails].compact
37
72
  end
38
73
  end
39
74
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "usage_entry"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module NestedExecution
@@ -29,10 +31,9 @@ module Smith
29
31
  # Roll up child totals AND usage_entries BEFORE the failed-step
30
32
  # check raises. Previously the rollup only fired on child success
31
33
  # — billable agent work inside a failed child was silently
32
- # dropped from the parent's totals/entries. The drift guard at
33
- # the hadithi boundary wouldn't catch it (parent rollups + entries
34
- # consistently undercount the same way; sum invariant still holds
35
- # incorrectly). Roll up first, then re-raise, so the parent's
34
+ # dropped from the parent's totals/entries. Simple aggregate drift
35
+ # checks can miss this when parent rollups and entries undercount
36
+ # the same way. Roll up first, then re-raise, so the parent's
36
37
  # terminal state reflects the child's billable work even when the
37
38
  # child failed.
38
39
  def handle_child_result(child_result)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ OptimizationState = Struct.new(
6
+ :config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
7
+ ) do
8
+ def initialize(config, prepared_input)
9
+ super(config, prepared_input, nil, nil, nil, nil, nil)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ OrchestrationState = Struct.new(
6
+ :config, :prepared_input, :orchestrator_class, :worker_class, :worker_results
7
+ ) do
8
+ def initialize(config, prepared_input)
9
+ super(config, prepared_input, nil, nil, nil)
10
+ end
11
+ end
12
+ end
13
+ end
@@ -1,29 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "securerandom"
4
+
5
+ require_relative "orchestration_state"
6
+ require_relative "worker_execution"
5
7
 
6
8
  module Smith
7
9
  class Workflow
8
10
  module OrchestratorWorker
9
- OrchestrationState = Struct.new(
10
- :config, :prepared_input, :orchestrator_class, :worker_class, :worker_results
11
- ) do
12
- def initialize(config, prepared_input)
13
- super(config, prepared_input, nil, nil, nil)
14
- end
15
- end
16
-
17
- WorkerExecution = Struct.new(:execution_id, :task, :output) do
18
- def self.run(worker_class, task, schema, budget_runner)
19
- new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
20
- end
21
- end
22
-
23
11
  private
24
12
 
25
13
  def dispatch_step(transition, prepared_input: nil)
26
- if transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
14
+ if transition.fanout? then execute_fanout_step(transition, prepared_input: prepared_input)
15
+ elsif transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
27
16
  elsif transition.nested? then execute_nested_workflow(transition)
28
17
  elsif transition.optimized? then execute_optimization_step(transition, prepared_input: prepared_input)
29
18
  elsif transition.orchestrated? then execute_orchestration_step(transition, prepared_input: prepared_input)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Parallel
6
+ class Cancellation < WorkflowError; end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Parallel
6
+ CancellationSignal = Struct.new(:cancelled, :mutex) do
7
+ def initialize
8
+ super(false, Mutex.new)
9
+ end
10
+
11
+ def cancel!
12
+ mutex.synchronize { self.cancelled = true }
13
+ end
14
+
15
+ def cancelled?
16
+ mutex.synchronize { cancelled }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,26 +2,17 @@
2
2
 
3
3
  require "concurrent"
4
4
 
5
+ require_relative "parallel/cancellation_signal"
6
+
5
7
  module Smith
6
8
  class Workflow
7
9
  class Parallel
8
- CancellationSignal = Struct.new(:cancelled, :mutex) do
9
- def initialize
10
- super(false, Mutex.new)
11
- end
12
-
13
- def cancel!
14
- mutex.synchronize { self.cancelled = true }
15
- end
16
-
17
- def cancelled?
18
- mutex.synchronize { cancelled }
19
- end
20
- end
21
-
22
10
  def self.resolve_branch_count(transition, context)
23
11
  count = transition.agent_opts[:count]
24
- count.respond_to?(:call) ? count.call(context) : (count || 1)
12
+ resolved = count.respond_to?(:call) ? count.call(context) : (count || 1)
13
+ return resolved if resolved.is_a?(Integer) && resolved.positive?
14
+
15
+ raise WorkflowError, "parallel branch count must be a positive integer"
25
16
  end
26
17
 
27
18
  def self.execute(branches:)
@@ -39,12 +30,17 @@ module Smith
39
30
  fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
40
31
 
41
32
  unless fulfilled
42
- error = reasons.compact.first
33
+ error = preferred_error(reasons)
43
34
  raise error
44
35
  end
45
36
 
46
37
  values
47
38
  end
39
+
40
+ def self.preferred_error(reasons)
41
+ errors = reasons.compact
42
+ errors.find { |error| !error.is_a?(Cancellation) } || errors.first
43
+ end
48
44
  end
49
45
  end
50
46
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "branch_env"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module ParallelExecution
@@ -50,10 +52,12 @@ module Smith
50
52
  Tool.current_ledger = ledger
51
53
  Tool.current_tool_result_collector = tool_result_collector
52
54
  Thread.current[:smith_last_agent_result] = nil
55
+ clear_failed_billable_attempts
53
56
  end
54
57
 
55
58
  def teardown_branch_context(env)
56
59
  Thread.current[:smith_last_agent_result] = nil
60
+ clear_failed_billable_attempts
57
61
  Tool.current_ledger = nil
58
62
  Tool.current_tool_result_collector = nil
59
63
  env.teardown_thread
@@ -68,7 +72,7 @@ module Smith
68
72
  end
69
73
 
70
74
  def check_cancellation!(signal)
71
- raise Smith::WorkflowError, "cancelled" if signal.cancelled?
75
+ raise Parallel::Cancellation, "cancelled" if signal.cancelled?
72
76
  end
73
77
  end
74
78
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "usage_entry"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module Persistence
@@ -20,7 +22,7 @@ module Smith
20
22
  total_tokens: @total_tokens || 0,
21
23
  tool_results: @tool_results || [],
22
24
  outcome: snapshot_outcome,
23
- # New durable fields for hadithi billing. All wrapped in
25
+ # Durable usage fields. All wrapped in
24
26
  # snapshot_value so non-JSON-safe runtime values (e.g.
25
27
  # custom Hash details on DeterministicStepFailure) get the
26
28
  # same deep-copy treatment as context/session_messages/etc.
@@ -163,9 +165,9 @@ module Smith
163
165
 
164
166
  h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
165
167
  {
166
- transition: h[:transition]&.to_sym,
167
- from: h[:from]&.to_sym,
168
- to: h[:to]&.to_sym,
168
+ transition: normalize_transition_name(h[:transition]),
169
+ from: normalize_state_name(h[:from]),
170
+ to: normalize_state_name(h[:to]),
169
171
  error_class: h[:error_class],
170
172
  error_family: h[:error_family],
171
173
  error_message: h[:error_message],
@@ -255,7 +257,7 @@ module Smith
255
257
  end
256
258
 
257
259
  def normalize_symbol_fields!(normalized)
258
- normalized[:state] = normalized[:state]&.to_sym
260
+ normalized[:state] = normalize_state_name(normalized[:state])
259
261
  if normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?(:kind)
260
262
  normalized[:outcome][:kind] = normalized[:outcome][:kind]&.to_sym
261
263
  elsif normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?("kind")
@@ -263,7 +265,36 @@ module Smith
263
265
  end
264
266
  return unless normalized.key?(:next_transition_name)
265
267
 
266
- normalized[:next_transition_name] = normalized[:next_transition_name]&.to_sym
268
+ normalized[:next_transition_name] = normalize_transition_name(normalized[:next_transition_name])
269
+ end
270
+
271
+ def normalize_transition_name(value)
272
+ return if value.nil?
273
+ transition = self.class.find_transition(value)
274
+ return transition.name if transition
275
+ return value unless value.is_a?(String)
276
+
277
+ symbolized = value.to_sym
278
+ transition = self.class.find_transition(symbolized)
279
+ return transition.name if transition
280
+
281
+ symbolized
282
+ end
283
+
284
+ def normalize_state_name(value)
285
+ return if value.nil?
286
+ return value if declared_state?(value)
287
+ return value unless value.is_a?(String)
288
+
289
+ symbolized = value.to_sym
290
+ return symbolized if declared_state?(symbolized)
291
+
292
+ symbolized
293
+ end
294
+
295
+ def declared_state?(value)
296
+ states = self.class.instance_variable_get(:@states) || []
297
+ states.include?(value)
267
298
  end
268
299
 
269
300
  def normalize_nested_hashes!(normalized)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module RetryExecution
6
+ private
7
+
8
+ def run_with_retry_policy(transition)
9
+ config = transition.retry_config
10
+ return run_guarded_step(transition) unless config
11
+
12
+ attempt = 0
13
+ begin
14
+ attempt += 1
15
+ run_guarded_step(transition)
16
+ rescue StandardError => e
17
+ raise unless retry_transition_error?(config, e, attempt)
18
+
19
+ sleep_for_retry(config, attempt)
20
+ retry
21
+ end
22
+ end
23
+
24
+ def retry_transition_error?(config, error, attempt)
25
+ return false if attempt >= config.fetch(:attempts)
26
+
27
+ classes = config.fetch(:error_classes)
28
+ if classes.any?
29
+ classes.any? { |error_class| error.is_a?(error_class) }
30
+ else
31
+ Smith::Errors.retryable?(error)
32
+ end
33
+ end
34
+
35
+ def sleep_for_retry(config, failed_attempt)
36
+ delay = retry_delay(config, failed_attempt)
37
+ sleep(delay) if delay.positive?
38
+ end
39
+
40
+ def retry_delay(config, failed_attempt)
41
+ delay = config.fetch(:backoff) * (2**[failed_attempt - 1, 0].max)
42
+ max_delay = config[:max_delay]
43
+ delay = [delay, max_delay].min if max_delay
44
+
45
+ jitter = config.fetch(:jitter)
46
+ delay += rand * jitter if jitter.positive?
47
+ delay = [delay, max_delay].min if max_delay
48
+ delay
49
+ end
50
+ end
51
+ end
52
+ end
@@ -4,16 +4,23 @@ module Smith
4
4
  class Workflow
5
5
  class Router
6
6
  def self.resolve(classifier_output, config, workflow_class:)
7
- validate!(classifier_output, config)
8
- transition_name = select_transition(classifier_output, config)
7
+ output = normalize_output(classifier_output)
8
+ validate!(output, config)
9
+ transition_name = select_transition(output, config)
9
10
  validate_transition_exists!(transition_name, workflow_class)
10
11
  transition_name
11
12
  end
12
13
 
14
+ def self.normalize_output(output)
15
+ return output unless output.is_a?(Hash)
16
+
17
+ output.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
18
+ end
19
+
13
20
  def self.validate!(output, config)
14
21
  validate_structure!(output)
15
22
  validate_confidence!(output[:confidence])
16
- validate_route_key!(output[:route].to_sym, output[:confidence], config)
23
+ validate_route_key!(normalize_route_key(output[:route]), output[:confidence], config)
17
24
  end
18
25
 
19
26
  def self.validate_structure!(output)
@@ -37,12 +44,16 @@ module Smith
37
44
 
38
45
  def self.select_transition(output, config)
39
46
  if output[:confidence] >= config[:confidence_threshold]
40
- config[:routes][output[:route].to_sym]
47
+ config[:routes][normalize_route_key(output[:route])]
41
48
  else
42
49
  config[:fallback]
43
50
  end
44
51
  end
45
52
 
53
+ def self.normalize_route_key(route)
54
+ route.to_s.strip.to_sym
55
+ end
56
+
46
57
  def self.validate_transition_exists!(transition_name, workflow_class)
47
58
  return if workflow_class.find_transition(transition_name)
48
59
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ # rubocop:disable Style/RedundantStructKeywordInit
6
+ RunResult = Struct.new(:state, :output, :steps, :total_cost, :total_tokens, :context, :session_messages,
7
+ :tool_results, :outcome, :usage_entries, keyword_init: true) do
8
+ def done?
9
+ state_named?(:done)
10
+ end
11
+
12
+ def failed?
13
+ state_named?(:failed)
14
+ end
15
+
16
+ def state_named?(name)
17
+ state == name || state.to_s == name.to_s
18
+ end
19
+
20
+ def terminal_output
21
+ output
22
+ end
23
+
24
+ def outcome_kind
25
+ outcome&.dig(:kind)
26
+ end
27
+
28
+ def outcome_payload
29
+ outcome&.dig(:payload)
30
+ end
31
+
32
+ def last_error
33
+ steps.reverse.map { |step| step[:error] }.compact.first
34
+ end
35
+
36
+ def failed_transition
37
+ failure_detail&.fetch(:transition)
38
+ end
39
+
40
+ def failure_detail
41
+ failed_step = steps.reverse.find { |step| step[:error] }
42
+ return nil unless failed_step
43
+
44
+ {
45
+ transition: failed_step[:transition],
46
+ from: failed_step[:from],
47
+ to: failed_step[:to],
48
+ error: failed_step[:error]
49
+ }
50
+ end
51
+ end
52
+ # rubocop:enable Style/RedundantStructKeywordInit
53
+ end
54
+ end