smith-agents 0.4.0 → 0.4.2

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.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class ReachabilityDiagnostics
7
+ attr_reader :graph, :reachable_transition_names
8
+
9
+ def initialize(graph, reachable_transition_names)
10
+ @graph = graph
11
+ @reachable_transition_names = reachable_transition_names
12
+ end
13
+
14
+ def to_a
15
+ (graph.transitions.keys - reachable_transition_names).filter_map do |transition_name|
16
+ next if auto_fail_transition?(transition_name)
17
+
18
+ diagnostic_for(transition_name)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def auto_fail_transition?(transition_name)
25
+ transition_name == :fail && graph.transitions[transition_name]&.from.nil?
26
+ end
27
+
28
+ def diagnostic_for(transition_name)
29
+ transition = graph.transitions.fetch(transition_name)
30
+ Diagnostic.new(
31
+ severity: :warning,
32
+ code: :unreachable_transition,
33
+ transition: transition_name,
34
+ state: transition.from,
35
+ message: "Transition #{ref(transition_name)} is not reachable from " \
36
+ "initial_state #{ref(graph.initial_state)}.",
37
+ suggestion: "Connect transition #{ref(transition_name)} from a reachable state or remove it."
38
+ )
39
+ end
40
+
41
+ def ref(value)
42
+ Reference.format(value)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ module Reference
7
+ def self.format(value)
8
+ return ":#{value}" if value.is_a?(Symbol)
9
+
10
+ value.inspect
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Report
7
+ attr_reader :status, :workflow_class, :initial_state, :states, :transitions, :diagnostics, :metrics
8
+
9
+ def initialize(**attributes)
10
+ @status = attributes.fetch(:status)
11
+ @workflow_class = attributes.fetch(:workflow_class)
12
+ @initial_state = attributes.fetch(:initial_state)
13
+ @states = attributes.fetch(:states)
14
+ @transitions = attributes.fetch(:transitions)
15
+ @diagnostics = attributes.fetch(:diagnostics)
16
+ @metrics = attributes.fetch(:metrics)
17
+ end
18
+
19
+ def valid?
20
+ errors.empty?
21
+ end
22
+
23
+ def errors
24
+ diagnostics.select { |diagnostic| diagnostic.severity == :error }
25
+ end
26
+
27
+ def warnings
28
+ diagnostics.select { |diagnostic| diagnostic.severity == :warning }
29
+ end
30
+
31
+ def suggestions
32
+ diagnostics.map(&:suggestion).compact.uniq
33
+ end
34
+
35
+ def to_h
36
+ {
37
+ status: status,
38
+ workflow_class: workflow_class,
39
+ initial_state: initial_state,
40
+ states: states,
41
+ transitions: transitions.map(&:to_h),
42
+ diagnostics: diagnostics.map(&:to_h),
43
+ suggestions: suggestions,
44
+ metrics: metrics
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class StateDiagnostics
7
+ attr_reader :graph
8
+
9
+ def initialize(graph)
10
+ @graph = graph
11
+ end
12
+
13
+ def to_a
14
+ [
15
+ *initial_state_diagnostics,
16
+ *state_reference_diagnostics
17
+ ]
18
+ end
19
+
20
+ private
21
+
22
+ def initial_state_diagnostics
23
+ return [] if graph.initial_state && graph.states.include?(graph.initial_state)
24
+
25
+ [
26
+ Diagnostic.new(
27
+ severity: :error,
28
+ code: :missing_initial_state,
29
+ state: graph.initial_state,
30
+ message: "Workflow initial_state is not declared as a state.",
31
+ suggestion: "Declare an initial_state and ensure it is included in the workflow states."
32
+ )
33
+ ]
34
+ end
35
+
36
+ def state_reference_diagnostics
37
+ graph.transitions.values.flat_map do |transition|
38
+ [
39
+ undefined_state_diagnostic(transition, :from, transition.from),
40
+ undefined_state_diagnostic(transition, :to, transition.to)
41
+ ].compact
42
+ end
43
+ end
44
+
45
+ def undefined_state_diagnostic(transition, edge, state)
46
+ return if edge == :from && state.nil?
47
+ return if graph.states.include?(state)
48
+
49
+ Diagnostic.new(
50
+ severity: :error,
51
+ code: :"undefined_#{edge}_state",
52
+ state: state,
53
+ transition: transition.name,
54
+ message: "Transition #{ref(transition.name)} references undefined #{edge} state #{ref(state)}.",
55
+ suggestion: "Declare state #{ref(state)} or update transition #{ref(transition.name)}."
56
+ )
57
+ end
58
+
59
+ def ref(value)
60
+ Reference.format(value)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Targets
7
+ attr_reader :transition
8
+
9
+ def self.for(transition)
10
+ new(transition).names
11
+ end
12
+
13
+ def self.router_for(transition)
14
+ new(transition).router_names
15
+ end
16
+
17
+ def initialize(transition)
18
+ @transition = transition
19
+ end
20
+
21
+ def names
22
+ names = [transition.success_transition, transition.failure_transition]
23
+ names.concat(router_names)
24
+ names.compact.uniq
25
+ end
26
+
27
+ def router_names
28
+ return [] unless transition.router_config
29
+
30
+ [
31
+ *transition.router_config.fetch(:routes).values,
32
+ transition.router_config.fetch(:fallback)
33
+ ]
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class TransitionDiagnostics
7
+ attr_reader :graph
8
+
9
+ def initialize(graph)
10
+ @graph = graph
11
+ end
12
+
13
+ def to_a
14
+ graph.transitions.values.flat_map do |transition|
15
+ [
16
+ transition_target_diagnostic(transition, :success_transition, transition.success_transition),
17
+ transition_target_diagnostic(transition, :failure_transition, transition.failure_transition),
18
+ *router_target_diagnostics(transition),
19
+ *target_state_mismatch_diagnostics(transition)
20
+ ].compact
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def transition_target_diagnostic(transition, code, target)
27
+ return if target.nil?
28
+ return if graph.transitions.key?(target)
29
+
30
+ Diagnostic.new(
31
+ severity: :error,
32
+ code: :"unresolved_#{code}",
33
+ transition: transition.name,
34
+ target: target,
35
+ message: "Transition #{ref(transition.name)} references missing transition #{ref(target)}.",
36
+ suggestion: "Declare transition #{ref(target)} or update transition #{ref(transition.name)}."
37
+ )
38
+ end
39
+
40
+ def router_target_diagnostics(transition)
41
+ return [] unless transition.router_config
42
+
43
+ Targets.router_for(transition).filter_map do |target|
44
+ transition_target_diagnostic(transition, :router_target, target)
45
+ end
46
+ end
47
+
48
+ def target_state_mismatch_diagnostics(transition)
49
+ Targets.for(transition).filter_map do |target_name|
50
+ target = graph.transitions[target_name]
51
+ next unless target
52
+ next if target.from.nil? || target.from == transition.to
53
+
54
+ mismatch_diagnostic(transition, target_name, target)
55
+ end
56
+ end
57
+
58
+ def mismatch_diagnostic(transition, target_name, target)
59
+ Diagnostic.new(
60
+ severity: :warning,
61
+ code: :target_from_state_mismatch,
62
+ transition: transition.name,
63
+ target: target_name,
64
+ state: transition.to,
65
+ message: "Transition #{ref(transition.name)} can route to #{ref(target_name)}, " \
66
+ "but #{ref(target_name)} starts from #{ref(target.from)} instead of #{ref(transition.to)}.",
67
+ suggestion: "Align #{ref(target_name)}'s from state with #{ref(transition.to)}, " \
68
+ "or remove the named route."
69
+ )
70
+ end
71
+
72
+ def ref(value)
73
+ Reference.format(value)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class TransitionSnapshot
7
+ KINDS = [
8
+ %i[deterministic deterministic?],
9
+ %i[router routed?],
10
+ %i[nested_workflow nested?],
11
+ %i[optimizer optimized?],
12
+ %i[orchestrator orchestrated?],
13
+ %i[fanout fanout?],
14
+ %i[parallel parallel?]
15
+ ].freeze
16
+
17
+ attr_reader :name, :from, :to, :kind, :success_transition, :failure_transition, :routes, :fallback,
18
+ :fanout_branches, :retry_policy
19
+
20
+ def self.from_transition(transition)
21
+ new(
22
+ name: transition.name,
23
+ from: transition.from,
24
+ to: transition.to,
25
+ kind: kind_for(transition),
26
+ success_transition: transition.success_transition,
27
+ failure_transition: transition.failure_transition,
28
+ routes: transition.router_config&.fetch(:routes, nil),
29
+ fallback: transition.router_config&.fetch(:fallback, nil),
30
+ fanout_branches: transition.fanout_config&.fetch(:branches, nil),
31
+ retry_policy: retry_policy_for(transition)
32
+ )
33
+ end
34
+
35
+ def self.retry_policy_for(transition)
36
+ config = transition.retry_config
37
+ return unless config
38
+
39
+ {
40
+ attempts: config.fetch(:attempts),
41
+ error_classes: config.fetch(:error_classes).map(&:name),
42
+ backoff: config.fetch(:backoff),
43
+ max_delay: config[:max_delay],
44
+ jitter: config.fetch(:jitter)
45
+ }.compact
46
+ end
47
+
48
+ def self.kind_for(transition)
49
+ kind = KINDS.find { |_name, predicate| transition.public_send(predicate) }
50
+ return kind.first if kind
51
+ return :agent if transition.agent_name
52
+
53
+ :noop
54
+ end
55
+
56
+ def initialize(**attributes)
57
+ @name = attributes.fetch(:name)
58
+ @from = attributes.fetch(:from)
59
+ @to = attributes.fetch(:to)
60
+ @kind = attributes.fetch(:kind)
61
+ @success_transition = attributes[:success_transition]
62
+ @failure_transition = attributes[:failure_transition]
63
+ @routes = attributes[:routes]
64
+ @fallback = attributes[:fallback]
65
+ @fanout_branches = attributes[:fanout_branches]
66
+ @retry_policy = attributes[:retry_policy]
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ name: name,
72
+ from: from,
73
+ to: to,
74
+ kind: kind,
75
+ success_transition: success_transition,
76
+ failure_transition: failure_transition,
77
+ routes: routes,
78
+ fallback: fallback,
79
+ fanout_branches: fanout_branches,
80
+ retry_policy: retry_policy
81
+ }.compact
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Validator
7
+ attr_reader :graph
8
+
9
+ def initialize(graph)
10
+ @graph = graph
11
+ end
12
+
13
+ def report
14
+ diagnostics = all_diagnostics
15
+
16
+ Report.new(
17
+ status: status_for(diagnostics),
18
+ workflow_class: graph.workflow_class.name,
19
+ initial_state: graph.initial_state,
20
+ states: graph.states,
21
+ transitions: graph.transition_snapshots,
22
+ diagnostics: diagnostics,
23
+ metrics: Metrics.new(graph, reachable_transition_names).to_h
24
+ )
25
+ end
26
+
27
+ private
28
+
29
+ def all_diagnostics
30
+ [
31
+ *StateDiagnostics.new(graph).to_a,
32
+ *TransitionDiagnostics.new(graph).to_a,
33
+ *ReachabilityDiagnostics.new(graph, reachable_transition_names).to_a
34
+ ]
35
+ end
36
+
37
+ def reachable_transition_names
38
+ @reachable_transition_names ||= Reachability.new(graph).transition_names
39
+ end
40
+
41
+ def status_for(diagnostics)
42
+ return :invalid if diagnostics.any? { |diagnostic| diagnostic.severity == :error }
43
+ return :warning if diagnostics.any?
44
+
45
+ :valid
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ attr_reader :workflow_class, :initial_state, :states, :transitions
7
+
8
+ def initialize(workflow_class:, initial_state:, states:, transitions:)
9
+ @workflow_class = workflow_class
10
+ @initial_state = initial_state
11
+ @states = Array(states).uniq.freeze
12
+ @transitions = transitions.dup.freeze
13
+ end
14
+
15
+ def validate
16
+ Validator.new(self).report
17
+ end
18
+
19
+ def transition_snapshots
20
+ transitions.values.map { |transition| TransitionSnapshot.from_transition(transition) }
21
+ end
22
+ end
23
+ end
24
+ end
25
+
26
+ require_relative "graph/reference"
27
+ require_relative "graph/diagnostic"
28
+ require_relative "graph/state_diagnostics"
29
+ require_relative "graph/reachability"
30
+ require_relative "graph/reachability_diagnostics"
31
+ require_relative "graph/metrics"
32
+ require_relative "graph/report"
33
+ require_relative "graph/targets"
34
+ require_relative "graph/transition_snapshot"
35
+ require_relative "graph/transition_diagnostics"
36
+ require_relative "graph/validator"
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ def self.graph
6
+ Graph.new(
7
+ workflow_class: self,
8
+ initial_state: initial_state,
9
+ states: @states || [],
10
+ transitions: @transitions || {}
11
+ )
12
+ end
13
+
14
+ def self.validate_graph
15
+ graph.validate
16
+ end
17
+ end
18
+ end
@@ -6,34 +6,52 @@ 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
+ return unless failure_name
26
+
27
+ fail_transition = self.class.find_transition(failure_name)
28
+ return unless fail_transition
29
+
30
+ @state = fail_transition.to
31
+ end
32
+
33
+ def run_workflow_input_guardrails
14
34
  wf_guardrails = self.class.guardrails
15
35
  Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
36
+ end
16
37
 
38
+ def run_agent_input_guardrails(agent_class)
17
39
  agent_guardrails = agent_class&.guardrails
18
40
  Guardrails::Runner.run_inputs(agent_guardrails, @context) if agent_guardrails
19
41
  end
20
42
 
21
- def run_output_guardrails(output, agent_class)
43
+ def run_workflow_output_guardrails(output)
22
44
  wf_guardrails = self.class.guardrails
23
45
  Guardrails::Runner.run_outputs(wf_guardrails, output) if wf_guardrails
46
+ end
24
47
 
48
+ def run_agent_output_guardrails(output, agent_class)
25
49
  agent_guardrails = agent_class&.guardrails
26
50
  Guardrails::Runner.run_outputs(agent_guardrails, output) if agent_guardrails
27
51
  end
28
52
 
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
53
+ def tool_guardrail_sources(agent_class)
54
+ [self.class.guardrails, agent_class&.guardrails].compact
37
55
  end
38
56
  end
39
57
  end
@@ -23,7 +23,8 @@ module Smith
23
23
  private
24
24
 
25
25
  def dispatch_step(transition, prepared_input: nil)
26
- if transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
26
+ if transition.fanout? then execute_fanout_step(transition, prepared_input: prepared_input)
27
+ elsif transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
27
28
  elsif transition.nested? then execute_nested_workflow(transition)
28
29
  elsif transition.optimized? then execute_optimization_step(transition, prepared_input: prepared_input)
29
30
  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
@@ -39,12 +39,17 @@ module Smith
39
39
  fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
40
40
 
41
41
  unless fulfilled
42
- error = reasons.compact.first
42
+ error = preferred_error(reasons)
43
43
  raise error
44
44
  end
45
45
 
46
46
  values
47
47
  end
48
+
49
+ def self.preferred_error(reasons)
50
+ errors = reasons.compact
51
+ errors.find { |error| !error.is_a?(Cancellation) } || errors.first
52
+ end
48
53
  end
49
54
  end
50
55
  end
@@ -50,10 +50,12 @@ module Smith
50
50
  Tool.current_ledger = ledger
51
51
  Tool.current_tool_result_collector = tool_result_collector
52
52
  Thread.current[:smith_last_agent_result] = nil
53
+ clear_failed_billable_attempts
53
54
  end
54
55
 
55
56
  def teardown_branch_context(env)
56
57
  Thread.current[:smith_last_agent_result] = nil
58
+ clear_failed_billable_attempts
57
59
  Tool.current_ledger = nil
58
60
  Tool.current_tool_result_collector = nil
59
61
  env.teardown_thread
@@ -68,7 +70,7 @@ module Smith
68
70
  end
69
71
 
70
72
  def check_cancellation!(signal)
71
- raise Smith::WorkflowError, "cancelled" if signal.cancelled?
73
+ raise Parallel::Cancellation, "cancelled" if signal.cancelled?
72
74
  end
73
75
  end
74
76
  end
@@ -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