smith-agents 0.4.2 → 0.4.4

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -3
  3. data/README.md +45 -2
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +41 -1
  6. data/lib/smith/agent/lifecycle.rb +30 -10
  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 +2 -0
  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 +5 -8
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +2 -0
  23. data/lib/smith/workflow/fanout_execution.rb +2 -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 +61 -20
  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 +20 -3
  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 +3 -15
  44. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  45. data/lib/smith/workflow/parallel.rb +6 -15
  46. data/lib/smith/workflow/parallel_execution.rb +2 -0
  47. data/lib/smith/workflow/persistence.rb +37 -6
  48. data/lib/smith/workflow/router.rb +15 -4
  49. data/lib/smith/workflow/run_result.rb +54 -0
  50. data/lib/smith/workflow/transition.rb +90 -4
  51. data/lib/smith/workflow/usage_entry.rb +30 -0
  52. data/lib/smith/workflow/worker_execution.rb +13 -0
  53. data/lib/smith/workflow.rb +40 -136
  54. data/lib/smith.rb +9 -0
  55. metadata +21 -1
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ class Graph
8
+ class RuntimeReadinessReport
9
+ extend Dry::Initializer
10
+
11
+ option :workflow_class
12
+ option :topology_report
13
+ option :runtime_diagnostics
14
+ option :metrics
15
+
16
+ def ready?
17
+ errors.empty?
18
+ end
19
+ alias valid? ready?
20
+
21
+ def status
22
+ return :not_ready if errors.any?
23
+ return :warning if warnings.any?
24
+
25
+ :ready
26
+ end
27
+
28
+ def topology_status
29
+ topology_report.status
30
+ end
31
+
32
+ def diagnostics
33
+ topology_report.diagnostics + runtime_diagnostics
34
+ end
35
+
36
+ def errors
37
+ diagnostics.select { |diagnostic| diagnostic.severity == :error }
38
+ end
39
+
40
+ def warnings
41
+ diagnostics.select { |diagnostic| diagnostic.severity == :warning }
42
+ end
43
+
44
+ def suggestions
45
+ diagnostics.map(&:suggestion).compact.uniq
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ status: status,
51
+ workflow_class: workflow_class,
52
+ topology_status: topology_status,
53
+ ready: ready?,
54
+ diagnostics: diagnostics.map(&:to_h),
55
+ runtime_diagnostics: runtime_diagnostics.map(&:to_h),
56
+ topology_diagnostics: topology_report.diagnostics.map(&:to_h),
57
+ suggestions: suggestions,
58
+ metrics: metrics,
59
+ graph: topology_report.to_h
60
+ }
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -21,6 +21,7 @@ module Smith
21
21
  def names
22
22
  names = [transition.success_transition, transition.failure_transition]
23
23
  names.concat(router_names)
24
+ names.concat(deterministic_route_names)
24
25
  names.compact.uniq
25
26
  end
26
27
 
@@ -32,6 +33,10 @@ module Smith
32
33
  transition.router_config.fetch(:fallback)
33
34
  ]
34
35
  end
36
+
37
+ def deterministic_route_names
38
+ transition.deterministic_routes || []
39
+ end
35
40
  end
36
41
  end
37
42
  end
@@ -16,6 +16,7 @@ module Smith
16
16
  transition_target_diagnostic(transition, :success_transition, transition.success_transition),
17
17
  transition_target_diagnostic(transition, :failure_transition, transition.failure_transition),
18
18
  *router_target_diagnostics(transition),
19
+ *deterministic_route_diagnostics(transition),
19
20
  *target_state_mismatch_diagnostics(transition)
20
21
  ].compact
21
22
  end
@@ -45,6 +46,12 @@ module Smith
45
46
  end
46
47
  end
47
48
 
49
+ def deterministic_route_diagnostics(transition)
50
+ Array(transition.deterministic_routes).filter_map do |target|
51
+ transition_target_diagnostic(transition, :deterministic_route, target)
52
+ end
53
+ end
54
+
48
55
  def target_state_mismatch_diagnostics(transition)
49
56
  Targets.for(transition).filter_map do |target_name|
50
57
  target = graph.transitions[target_name]
@@ -1,9 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry-initializer"
4
+
5
+ require_relative "fanout_contract"
6
+ require_relative "optimization_contract"
7
+ require_relative "orchestration_contract"
8
+
3
9
  module Smith
4
10
  class Workflow
5
11
  class Graph
6
12
  class TransitionSnapshot
13
+ extend Dry::Initializer
14
+
7
15
  KINDS = [
8
16
  %i[deterministic deterministic?],
9
17
  %i[router routed?],
@@ -14,22 +22,64 @@ module Smith
14
22
  %i[parallel parallel?]
15
23
  ].freeze
16
24
 
17
- attr_reader :name, :from, :to, :kind, :success_transition, :failure_transition, :routes, :fallback,
18
- :fanout_branches, :retry_policy
25
+ option :name
26
+ option :from
27
+ option :to
28
+ option :kind
29
+ option :success_transition, default: proc {}
30
+ option :failure_transition, default: proc {}
31
+ option :routes, default: proc {}
32
+ option :fallback, default: proc {}
33
+ option :deterministic_routes, default: proc {}
34
+ option :fanout_branches, default: proc {}
35
+ option :fanout, default: proc {}
36
+ option :optimization, default: proc {}
37
+ option :orchestration, default: proc {}
38
+ option :retry_policy, default: proc {}
39
+
40
+ def self.from_transition(transition, workflow_class: nil)
41
+ new(**attributes_for(transition, workflow_class: workflow_class))
42
+ end
19
43
 
20
- def self.from_transition(transition)
21
- new(
44
+ def self.attributes_for(transition, workflow_class:)
45
+ {
22
46
  name: transition.name,
23
47
  from: transition.from,
24
48
  to: transition.to,
25
- kind: kind_for(transition),
49
+ kind: kind_for(transition)
50
+ }.merge(routing_attributes(transition), contract_attributes(transition, workflow_class: workflow_class))
51
+ end
52
+
53
+ def self.routing_attributes(transition)
54
+ {
26
55
  success_transition: transition.success_transition,
27
56
  failure_transition: transition.failure_transition,
28
57
  routes: transition.router_config&.fetch(:routes, nil),
29
58
  fallback: transition.router_config&.fetch(:fallback, nil),
30
- fanout_branches: transition.fanout_config&.fetch(:branches, nil),
59
+ deterministic_routes: transition.deterministic_routes,
60
+ fanout_branches: transition.fanout_config&.fetch(:branches, nil)
61
+ }
62
+ end
63
+
64
+ def self.contract_attributes(transition, workflow_class:)
65
+ {
66
+ fanout: fanout_for(transition, workflow_class: workflow_class),
67
+ optimization: optimization_for(transition, workflow_class: workflow_class),
68
+ orchestration: orchestration_for(transition, workflow_class: workflow_class),
31
69
  retry_policy: retry_policy_for(transition)
32
- )
70
+ }
71
+ end
72
+
73
+ def self.fanout_for(transition, workflow_class:)
74
+ FanoutContract.from_transition(transition, workflow_class: workflow_class)
75
+ end
76
+
77
+ def self.optimization_for(transition, workflow_class:)
78
+ OptimizationContract.from_transition(transition, workflow_class: workflow_class)
79
+ end
80
+
81
+ def self.orchestration_for(transition, workflow_class:)
82
+ OrchestrationContract.from_transition(transition, workflow_class: workflow_class)
33
83
  end
34
84
 
35
85
  def self.retry_policy_for(transition)
@@ -53,19 +103,6 @@ module Smith
53
103
  :noop
54
104
  end
55
105
 
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
106
  def to_h
70
107
  {
71
108
  name: name,
@@ -76,7 +113,11 @@ module Smith
76
113
  failure_transition: failure_transition,
77
114
  routes: routes,
78
115
  fallback: fallback,
116
+ deterministic_routes: deterministic_routes,
79
117
  fanout_branches: fanout_branches,
118
+ fanout: fanout,
119
+ optimization: optimization,
120
+ orchestration: orchestration,
80
121
  retry_policy: retry_policy
81
122
  }.compact
82
123
  end
@@ -16,8 +16,14 @@ module Smith
16
16
  Validator.new(self).report
17
17
  end
18
18
 
19
+ def runtime_readiness(visited: nil)
20
+ RuntimeReadiness.new(self, visited: visited).report
21
+ end
22
+
19
23
  def transition_snapshots
20
- transitions.values.map { |transition| TransitionSnapshot.from_transition(transition) }
24
+ transitions.values.map do |transition|
25
+ TransitionSnapshot.from_transition(transition, workflow_class: workflow_class)
26
+ end
21
27
  end
22
28
  end
23
29
  end
@@ -29,8 +35,17 @@ require_relative "graph/state_diagnostics"
29
35
  require_relative "graph/reachability"
30
36
  require_relative "graph/reachability_diagnostics"
31
37
  require_relative "graph/metrics"
38
+ require_relative "graph/nested_readiness_diagnostics"
32
39
  require_relative "graph/report"
40
+ require_relative "graph/runtime_binding_diagnostic_builder"
41
+ require_relative "graph/runtime_binding_diagnostics"
42
+ require_relative "graph/runtime_readiness_report"
43
+ require_relative "graph/runtime_readiness_metrics"
33
44
  require_relative "graph/targets"
45
+ require_relative "graph/fanout_contract"
46
+ require_relative "graph/optimization_contract"
47
+ require_relative "graph/orchestration_contract"
34
48
  require_relative "graph/transition_snapshot"
35
49
  require_relative "graph/transition_diagnostics"
36
50
  require_relative "graph/validator"
51
+ require_relative "graph/runtime_readiness"
@@ -14,5 +14,9 @@ module Smith
14
14
  def self.validate_graph
15
15
  graph.validate
16
16
  end
17
+
18
+ def self.runtime_readiness
19
+ graph.runtime_readiness
20
+ end
17
21
  end
18
22
  end
@@ -20,16 +20,33 @@ module Smith
20
20
  run_agent_output_guardrails(output, agent_class)
21
21
  end
22
22
 
23
- def handle_step_failure(transition, _error)
23
+ def handle_step_failure(transition, error)
24
24
  failure_name = transition.failure_transition
25
- return unless failure_name
25
+ raise error unless failure_name
26
26
 
27
27
  fail_transition = self.class.find_transition(failure_name)
28
- return unless fail_transition
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
29
35
 
30
36
  @state = fail_transition.to
31
37
  end
32
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
+
33
50
  def run_workflow_input_guardrails
34
51
  wf_guardrails = self.class.guardrails
35
52
  Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
@@ -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,25 +1,13 @@
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)
@@ -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:)
@@ -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
@@ -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)
@@ -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