smith-agents 0.4.1 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fce22e9b87caf5c01417a5e7d8fe7a6f4ee179abebc61644074400452a078791
4
- data.tar.gz: e7d2ae59746ff545f5d45215bcec20dea95ee82a37d7fa765c4ded95484d4029
3
+ metadata.gz: 1c5a7f554819eb342aa2284fb010eba8126f22fba1348683a36fbc4fa9d5383f
4
+ data.tar.gz: b72e10a5415340c5b337751c0cc92f2f59ed0ddb62850e03746df217be374721
5
5
  SHA512:
6
- metadata.gz: 8d8515c459487f57c20301581e6f1d5cc786f8bfe7089b4e628548145ac56e0c9c93e9560ac37f1fa7818f95a71a1c8753cb275267b341fb39ca4bf63d5e5f3e
7
- data.tar.gz: b3e05ca16fc7fec62e8172d9d6ccb84b0bc3f7546af22b9f47c8a36ce4b7e6e5bfb35b0fb47eb51c0b86cf4ab4fbe47e651406c1666fcc63d4b2c16b0ccb0bf9
6
+ metadata.gz: 75d149a61196f2c0cd22748ff6d5a6c4530bc9d91c61b163c027e44d555b9fd0f1ca5ef0a283c6025d6906b6d984923265fba05190fa3da37a60cfe864e5f6f8
7
+ data.tar.gz: 906bc56189df0f84ad62cb643ed8141a05780a328bf9a3770db55c575d71db86b94ad39e91517ba0e2d76f05942f84358dc234b2e685ad0701dba059bed65d36
data/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
8
8
 
9
9
  No unreleased changes.
10
10
 
11
+ ## [0.4.2] - 2026-07-02
12
+
13
+ Patch release for bounded fan-out and retry workflow primitives. This remains
14
+ workflow-first and host-owned: Smith executes declared transitions and exposes
15
+ inspection metadata, while durable scheduling, long waits, tool adapter
16
+ contracts, and deployment packaging stay with the host application.
17
+
18
+ ### Added
19
+
20
+ - `fan_out branches: {...}` transition DSL for bounded heterogeneous
21
+ multi-agent fan-out with stable branch keys and named aggregate results.
22
+ - `retry_on` transition DSL for bounded local retries using explicit error
23
+ classes or Smith's built-in retryability classifier.
24
+ - Graph inspection metadata for `:fanout` transitions and retry policy details.
25
+
26
+ ### Changed
27
+
28
+ - Fan-out branch execution preserves branch identity, branch-specific budgets,
29
+ agent guardrails, tool guardrails, deadlines, and usage accounting.
30
+ - Parallel/fan-out failure handling now prefers the initiating branch error over
31
+ cooperative cancellation errors.
32
+ - Failed-but-billable provider attempts are included in budget reconciliation
33
+ for retry, fallback, and fan-out settlement paths.
34
+ - Retry `max_delay` remains a hard cap even when jitter is configured.
35
+
36
+ ### Test coverage
37
+
38
+ - Default suite: 880 examples, 0 failures.
39
+ - Practical gem-level execution probe covering heterogeneous `fan_out`,
40
+ same-agent parallel execution, `retry_on`, failed-but-billable budget
41
+ settlement, cancellation cause preservation, branch input guardrail ordering
42
+ before session preparation, graph metadata, and invalid declaration rejection.
43
+ - Added focused coverage for heterogeneous fan-out, retry policies,
44
+ failed-but-billable retry budget accounting, cancellation cause preservation,
45
+ and graph inspection metadata.
46
+
11
47
  ## [0.4.1] - 2026-06-28
12
48
 
13
49
  Patch release for static workflow graph inspection. This is additive and diagnostic-only: Smith exposes declared workflow topology for hosts to render, lint, or cache without executing agents, advancing state, owning progress projection, or changing durability/recovery boundaries.
data/README.md CHANGED
@@ -5,11 +5,19 @@ Workflow-first multi-agent orchestration for Ruby. Smith sits on top of `RubyLLM
5
5
  > [!WARNING]
6
6
  > Smith is pre-1.0. Expect contract tightening between minor versions. Pin to an exact version in production.
7
7
 
8
+ ## Verification Discipline
9
+
10
+ Tests are required, but they are never enough for runtime primitive changes.
11
+ Every Smith workflow slice must also run practical gem-level execution probes.
12
+ When a host application consumes unreleased Smith changes, point that host app at
13
+ the local Smith repository and exercise the changed workflow paths in the host
14
+ environment before calling the slice complete.
15
+
8
16
  ## Installation
9
17
 
10
18
  ```ruby
11
19
  # Gemfile
12
- gem "smith-agents", "~> 0.2.0", require: "smith"
20
+ gem "smith-agents", "~> 0.4.2", require: "smith"
13
21
  ```
14
22
 
15
23
  ```bash
@@ -87,6 +95,7 @@ end
87
95
  | Pipeline | sequential transitions | Multi-step workflow with explicit success/failure routing. |
88
96
  | Router | `route :classifier, routes: {...}` | Branch on a classifier agent's output. |
89
97
  | Parallel fan-out | `execute :agent, parallel: true` | Concurrent agent calls under one ledger. |
98
+ | Heterogeneous fan-out | `fan_out branches: {...}` | Concurrent calls to different agents with named branch results. |
90
99
  | Nested workflow | `workflow OtherWorkflow` | Reuse a subflow as one transition. |
91
100
  | Evaluator-Optimizer | `optimize generator:, evaluator:, ...` | Generate-then-critique refinement loops. |
92
101
  | Orchestrator-Worker | `orchestrate orchestrator:, worker:, ...` | Dynamic task fan-out with delegation rounds. |
@@ -230,6 +239,19 @@ Smith::Errors.retryable_classes
230
239
  # => [Smith::AgentError, Smith::DeadlineExceeded] (for ActiveJob retry_on)
231
240
  ```
232
241
 
242
+ Workflow transitions can also declare a bounded local retry policy:
243
+
244
+ ```ruby
245
+ transition :draft, from: :idle, to: :done do
246
+ execute :writer
247
+ retry_on Smith::AgentError, attempts: 3, backoff: 0.1, max_delay: 1.0
248
+ end
249
+ ```
250
+
251
+ When no classes are passed, `retry_on` uses `Smith::Errors.retryable?`.
252
+ This is a bounded local transition retry policy. Durable scheduling, long waits,
253
+ and external idempotency guarantees remain host-owned.
254
+
233
255
  ## Development
234
256
 
235
257
  ```bash
@@ -238,4 +260,5 @@ bundle exec rspec
238
260
  bundle exec rubocop
239
261
  ```
240
262
 
241
- 770 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the 0.2.0 surface and [`UPSTREAM_PROPOSAL.md`](UPSTREAM_PROPOSAL.md) for the vendored Responses adapter retirement path.
263
+ 880 examples, MIT licensed. See [`CHANGELOG.md`](CHANGELOG.md) for the current
264
+ release surface.
data/docs/PATTERNS.md CHANGED
@@ -239,7 +239,47 @@ Why this is valuable:
239
239
  - branch failures discard step output and route through normal failure handling
240
240
  - prepared input is reused consistently across branches
241
241
 
242
- ## Example 6: Nested Workflows
242
+ ## Example 6: Heterogeneous Fan-Out
243
+
244
+ Use heterogeneous fan-out when different specialists should run concurrently and return named branch results under one workflow transition.
245
+
246
+ ```ruby
247
+ class StaticReviewAgent < Smith::Agent
248
+ register_as :static_review_agent
249
+ model "gpt-4.1-nano"
250
+ end
251
+
252
+ class SecurityReviewAgent < Smith::Agent
253
+ register_as :security_review_agent
254
+ model "gpt-4.1-nano"
255
+ end
256
+
257
+ class CodeReviewWorkflow < Smith::Workflow
258
+ initial_state :idle
259
+ state :reviewed
260
+ state :failed
261
+
262
+ transition :review, from: :idle, to: :reviewed do
263
+ fan_out branches: {
264
+ static: :static_review_agent,
265
+ security: :security_review_agent
266
+ }
267
+ on_failure :fail
268
+ end
269
+ end
270
+ ```
271
+
272
+ What you get:
273
+
274
+ - stable branch identity in the step output
275
+ - branch-specific agent budgets, guardrails, tools, and model configuration
276
+ - one shared prepared input for the transition
277
+ - one shared transition result, so downstream joins remain explicit in the workflow
278
+ - branch failures discard partial output and route through normal failure handling
279
+
280
+ Use same-agent `parallel: true` for repeated homogeneous work. Use `fan_out` when branches are different agents with different responsibilities.
281
+
282
+ ## Example 7: Nested Workflows
243
283
 
244
284
  Use nested workflows when one part of the system deserves to be a reusable subflow with its own states and transitions.
245
285
 
@@ -281,7 +321,7 @@ What you get:
281
321
  - nested best-known token/cost totals roll up into the parent result
282
322
  - artifact scope is preserved across nesting
283
323
 
284
- ## Example 7: Evaluator-Optimizer
324
+ ## Example 8: Evaluator-Optimizer
285
325
 
286
326
  Use `optimize` when one agent generates candidates and another agent evaluates whether the result is acceptable.
287
327
 
@@ -335,7 +375,7 @@ Why this matters:
335
375
  - exhaustion, malformed evaluator output, and convergence without acceptance fail normally
336
376
  - costs and token usage from the full loop roll into the workflow totals
337
377
 
338
- ## Example 8: Orchestrator-Worker
378
+ ## Example 9: Orchestrator-Worker
339
379
 
340
380
  Use `orchestrate` when you need an orchestrator that can emit structured tasks for workers and later decide when the system is done.
341
381
 
@@ -489,4 +529,3 @@ The yielded step object exposes a narrow, read-heavy surface:
489
529
  - **Persistence**: Context writes and written outcomes survive `to_state`/`from_state`. The block itself (a Proc) lives on the class-level Transition and is never serialized.
490
530
  - **Trace**: Emits `:deterministic_step` traces for start, success/routed, and failure. When a step writes an outcome, the trace includes `outcome_kind`.
491
531
  - **Mutual exclusivity**: `compute` and `run` cannot be combined with `execute`, `route`, `workflow`, `optimize`, or `orchestrate`. A transition declares exactly one primary execution body.
492
-
@@ -46,10 +46,10 @@ module Smith
46
46
 
47
47
  def build_model_chain(agent_class)
48
48
  primary = if agent_class.respond_to?(:model_block) && agent_class.model_block
49
- resolve_dynamic_model(agent_class)
50
- else
51
- agent_class.chat_kwargs[:model]
52
- end
49
+ resolve_dynamic_model(agent_class)
50
+ else
51
+ agent_class.chat_kwargs[:model]
52
+ end
53
53
  fallbacks = agent_class.fallback_models || []
54
54
  [primary, *fallbacks].compact
55
55
  end
@@ -103,8 +103,8 @@ module Smith
103
103
 
104
104
  declared = agent_class.inputs || []
105
105
  user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
106
- user_declared.each_with_object({}) do |name, kwargs|
107
- kwargs[name] = @context[name]
106
+ user_declared.to_h do |name|
107
+ [name, @context[name]]
108
108
  end
109
109
  end
110
110
 
@@ -131,7 +131,9 @@ module Smith
131
131
 
132
132
  combined_contents = existing_system_contents + prepared_system_contents
133
133
  return if combined_contents.empty?
134
- return prepared_system_messages.each { |message| chat.add_message(message) } unless combined_contents.all?(String)
134
+ unless combined_contents.all?(String)
135
+ return prepared_system_messages.each { |message| chat.add_message(message) }
136
+ end
135
137
 
136
138
  if chat.respond_to?(:with_instructions)
137
139
  chat.with_instructions(combined_contents.join("\n\n"))
@@ -178,6 +180,8 @@ module Smith
178
180
  agent_result = Workflow::AgentResult.new(
179
181
  content: nil, input_tokens: input, output_tokens: output, cost: cost, model_used: model_id
180
182
  )
183
+ Thread.current[:smith_failed_agent_results] ||= []
184
+ Thread.current[:smith_failed_agent_results] << agent_result
181
185
  record_usage(agent_class, agent_result, :failed_attempt, model_id)
182
186
  end
183
187
 
data/lib/smith/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.4.1"
4
+ VERSION = "0.4.2"
5
5
  end
@@ -30,19 +30,33 @@ module Smith
30
30
  def reconcile_branch_budget(ledger, estimates, agent_result: nil)
31
31
  return unless ledger && estimates
32
32
 
33
- actuals = extract_actuals(agent_result)
33
+ actuals = extract_actuals(agent_results_for_settlement(agent_result))
34
34
  estimates.each do |dim, amt|
35
35
  ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
36
36
  end
37
37
  end
38
38
 
39
- def extract_actuals(agent_result)
39
+ def extract_actuals(agent_results)
40
+ results = Array(agent_results).compact
41
+
40
42
  {
41
- tokens: (agent_result&.input_tokens || 0) + (agent_result&.output_tokens || 0),
42
- cost: agent_result&.cost || 0
43
+ tokens: results.sum { |result| (result.input_tokens || 0) + (result.output_tokens || 0) },
44
+ cost: results.sum { |result| result.cost || 0 }
43
45
  }
44
46
  end
45
47
 
48
+ def agent_results_for_settlement(agent_result = nil)
49
+ [*failed_billable_attempts, agent_result].compact
50
+ end
51
+
52
+ def failed_billable_attempts
53
+ Array(Thread.current[:smith_failed_agent_results])
54
+ end
55
+
56
+ def clear_failed_billable_attempts
57
+ Thread.current[:smith_failed_agent_results] = []
58
+ end
59
+
46
60
  def actual_for_dimension(dim, actual_tokens, actual_cost = 0)
47
61
  return actual_tokens if TOKEN_DIMENSIONS.include?(dim)
48
62
  return actual_cost if COST_DIMENSIONS.include?(dim)
@@ -59,7 +73,7 @@ module Smith
59
73
  def settle_budget_on_failure(ledger, estimates, agent_result)
60
74
  return unless ledger && estimates
61
75
 
62
- if agent_result
76
+ if agent_result || failed_billable_attempts.any?
63
77
  reconcile_branch_budget(ledger, estimates, agent_result: agent_result)
64
78
  else
65
79
  release_branch_budget(ledger, estimates)
@@ -85,6 +99,12 @@ module Smith
85
99
  { branch: index, agent: transition.agent_name, output: agent_result ? agent_result.content : result }
86
100
  end
87
101
 
102
+ def finalize_named_branch(branch_key, agent_name, result, ledger, reserved)
103
+ agent_result = result.is_a?(Workflow::AgentResult) ? result : nil
104
+ reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
105
+ { branch: branch_key, agent: agent_name, output: agent_result ? agent_result.content : result }
106
+ end
107
+
88
108
  def estimate_for_dimension(dim, limit, branch_count)
89
109
  return 0 unless BUDGET_DIMENSIONS.include?(dim)
90
110
 
@@ -91,7 +91,7 @@ module Smith
91
91
  when Hash
92
92
  deep_symbolize_evaluation(evaluation)
93
93
  when String
94
- parsed = (JSON.parse(evaluation, symbolize_names: true) rescue nil)
94
+ parsed = parse_evaluation_json(evaluation)
95
95
  parsed.is_a?(Hash) ? parsed : evaluation
96
96
  else
97
97
  evaluation
@@ -155,9 +155,12 @@ module Smith
155
155
 
156
156
  def invoke_agent_with_budget(agent_class, prepared_input)
157
157
  Thread.current[:smith_last_agent_result] = nil
158
+ clear_failed_billable_attempts
158
159
  with_agent_context(agent_class) do
159
160
  invoke_with_call_ledger(agent_class, prepared_input)
160
161
  end
162
+ ensure
163
+ clear_failed_billable_attempts
161
164
  end
162
165
 
163
166
  def invoke_with_call_ledger(agent_class, prepared_input)
@@ -179,12 +182,20 @@ module Smith
179
182
  # routes through on_threshold and returns the resulting value
180
183
  # (non-nil terminates the loop with that as the step output).
181
184
  def check_improvement_threshold!(evaluation, state, round)
182
- return nil unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
185
+ unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
186
+ return nil
187
+ end
183
188
 
184
189
  handle_exit(state, :on_threshold,
185
190
  "optimization improvement below threshold after round #{round + 1}")
186
191
  end
187
192
 
193
+ def parse_evaluation_json(evaluation)
194
+ JSON.parse(evaluation, symbolize_names: true)
195
+ rescue JSON::ParserError
196
+ nil
197
+ end
198
+
188
199
  def prepare_generator_input(prepared_input, round, prior_candidate, feedback)
189
200
  return prepared_input if round.zero?
190
201
 
@@ -8,13 +8,15 @@ module Smith
8
8
  include EvaluatorOptimizer
9
9
  include OrchestratorWorker
10
10
  include ParallelExecution
11
+ include FanoutExecution
12
+ include RetryExecution
11
13
  include DeterministicExecution
12
14
 
13
15
  private
14
16
 
15
17
  def execute_step(transition)
16
18
  setup_step_context
17
- output = with_scoped_artifacts { run_guarded_step(transition) }
19
+ output = with_scoped_artifacts { run_with_retry_policy(transition) }
18
20
  complete_step(transition, output)
19
21
  rescue StandardError => e
20
22
  @outcome = nil
@@ -40,6 +42,7 @@ module Smith
40
42
 
41
43
  def run_guarded_step(transition)
42
44
  return dispatch_step(transition) if transition.deterministic?
45
+ return run_guarded_fanout_step(transition) if transition.fanout?
43
46
 
44
47
  agent_class = resolve_agent_class(transition)
45
48
  run_input_guardrails(agent_class)
@@ -93,6 +96,7 @@ module Smith
93
96
 
94
97
  def execute_serial_step(transition, prepared_input: nil)
95
98
  Thread.current[:smith_last_agent_result] = nil
99
+ clear_failed_billable_attempts
96
100
  ledger = effective_call_ledger
97
101
  reserved = reserve_for_serial(transition, ledger)
98
102
  begin
@@ -104,6 +108,7 @@ module Smith
104
108
  ensure
105
109
  settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
106
110
  Thread.current[:smith_last_agent_result] = nil
111
+ clear_failed_billable_attempts
107
112
  end
108
113
  end
109
114
 
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module FanoutExecution
6
+ private
7
+
8
+ def run_guarded_fanout_step(transition)
9
+ branches = transition.fanout_config.fetch(:branches)
10
+ branch_agent_classes = fanout_agent_classes(transition, branches)
11
+ run_workflow_input_guardrails
12
+ run_fanout_agent_input_guardrails(branch_agent_classes)
13
+ prepared_input = build_session&.prepare!
14
+ output = execute_fanout_step(
15
+ transition,
16
+ branches: branches,
17
+ branch_agent_classes: branch_agent_classes,
18
+ prepared_input: prepared_input
19
+ )
20
+ run_workflow_output_guardrails(output)
21
+ output
22
+ end
23
+
24
+ def execute_fanout_step(transition, branches: nil, branch_agent_classes: nil, prepared_input: nil)
25
+ branches ||= transition.fanout_config.fetch(:branches)
26
+ branch_agent_classes ||= fanout_agent_classes(transition, branches)
27
+ env = BranchEnv.new(
28
+ prepared_input: prepared_input,
29
+ guardrail_sources: nil,
30
+ scoped_store: propagate_scoped_artifacts,
31
+ branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
32
+ deadline: wall_clock_deadline
33
+ )
34
+
35
+ branch_calls = branches.map do |branch_key, agent_name|
36
+ proc do |signal|
37
+ run_fanout_branch(branch_key, agent_name, branch_agent_classes.fetch(branch_key), env, signal)
38
+ end
39
+ end
40
+
41
+ Parallel.execute(branches: branch_calls)
42
+ end
43
+
44
+ def run_fanout_branch(branch_key, agent_name, agent_class, env, signal)
45
+ setup_fanout_branch_context(env, @ledger, agent_class)
46
+
47
+ with_agent_context(agent_class) do
48
+ branch_ledger = effective_call_ledger
49
+ reserved = reserve_fanout_branch_call(branch_ledger, env.branch_estimates[branch_key], agent_class)
50
+ begin
51
+ result = guarded_fanout_branch_call(agent_class, env, signal)
52
+ finalize_named_branch(branch_key, agent_name, result, branch_ledger, reserved).tap { reserved = nil }
53
+ ensure
54
+ settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
55
+ end
56
+ end
57
+ ensure
58
+ teardown_branch_context(env)
59
+ end
60
+
61
+ def guarded_fanout_branch_call(agent_class, env, signal)
62
+ check_cancellation!(signal)
63
+ check_deadline!
64
+ result = agent_class.model_configured? ? invoke_agent(agent_class, env.prepared_input) : nil
65
+ output = result.is_a?(AgentResult) ? result.content : result
66
+ validate_data_volume!(output, agent_class)
67
+ run_agent_output_guardrails(output, agent_class)
68
+ check_cancellation!(signal)
69
+ result
70
+ end
71
+
72
+ def setup_fanout_branch_context(env, ledger, agent_class)
73
+ setup_branch_context(env, ledger)
74
+ apply_tool_guardrails(agent_class)
75
+ end
76
+
77
+ def reserve_fanout_branch_call(branch_ledger, branch_estimates, agent_class)
78
+ return reserve_branch_budget(branch_ledger, branch_estimates: branch_estimates) if @ledger
79
+
80
+ reserve_serial_budget(branch_ledger, agent_budget: agent_class&.budget) if branch_ledger
81
+ end
82
+
83
+ def fanout_agent_classes(transition, branches)
84
+ branches.to_h do |branch_key, agent_name|
85
+ [branch_key, resolve_fanout_agent_class(transition, agent_name)]
86
+ end
87
+ end
88
+
89
+ def run_fanout_agent_input_guardrails(branch_agent_classes)
90
+ branch_agent_classes.each_value do |agent_class|
91
+ run_agent_input_guardrails(agent_class)
92
+ end
93
+ end
94
+
95
+ def fanout_branch_estimates(branches, branch_agent_classes)
96
+ return {} unless @ledger
97
+
98
+ branch_count = branches.length
99
+ branches.each_with_object({}) do |(branch_key, _agent_name), map|
100
+ agent_class = branch_agent_classes.fetch(branch_key)
101
+ map[branch_key] = compute_branch_estimates(
102
+ @ledger,
103
+ branch_count: branch_count,
104
+ agent_budget: agent_class&.budget
105
+ )
106
+ end
107
+ end
108
+
109
+ def resolve_fanout_agent_class(transition, agent_name)
110
+ Agent::Registry.fetch!(
111
+ agent_name,
112
+ workflow_class: self.class,
113
+ transition_name: transition&.name,
114
+ role: :fanout_agent
115
+ )
116
+ end
117
+ end
118
+ end
119
+ end
@@ -10,10 +10,12 @@ module Smith
10
10
  %i[nested_workflow nested?],
11
11
  %i[optimizer optimized?],
12
12
  %i[orchestrator orchestrated?],
13
+ %i[fanout fanout?],
13
14
  %i[parallel parallel?]
14
15
  ].freeze
15
16
 
16
- attr_reader :name, :from, :to, :kind, :success_transition, :failure_transition, :routes, :fallback
17
+ attr_reader :name, :from, :to, :kind, :success_transition, :failure_transition, :routes, :fallback,
18
+ :fanout_branches, :retry_policy
17
19
 
18
20
  def self.from_transition(transition)
19
21
  new(
@@ -24,10 +26,25 @@ module Smith
24
26
  success_transition: transition.success_transition,
25
27
  failure_transition: transition.failure_transition,
26
28
  routes: transition.router_config&.fetch(:routes, nil),
27
- fallback: transition.router_config&.fetch(:fallback, 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)
28
32
  )
29
33
  end
30
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
+
31
48
  def self.kind_for(transition)
32
49
  kind = KINDS.find { |_name, predicate| transition.public_send(predicate) }
33
50
  return kind.first if kind
@@ -45,6 +62,8 @@ module Smith
45
62
  @failure_transition = attributes[:failure_transition]
46
63
  @routes = attributes[:routes]
47
64
  @fallback = attributes[:fallback]
65
+ @fanout_branches = attributes[:fanout_branches]
66
+ @retry_policy = attributes[:retry_policy]
48
67
  end
49
68
 
50
69
  def to_h
@@ -56,7 +75,9 @@ module Smith
56
75
  success_transition: success_transition,
57
76
  failure_transition: failure_transition,
58
77
  routes: routes,
59
- fallback: fallback
78
+ fallback: fallback,
79
+ fanout_branches: fanout_branches,
80
+ retry_policy: retry_policy
60
81
  }.compact
61
82
  end
62
83
  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
@@ -5,7 +5,7 @@ module Smith
5
5
  class Transition
6
6
  attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
7
7
  :router_config, :workflow_class, :optimization_config, :orchestrator_config,
8
- :deterministic_block, :deterministic_kind
8
+ :fanout_config, :retry_config, :deterministic_block, :deterministic_kind
9
9
 
10
10
  def initialize(name, from:, to:, &)
11
11
  @name = name
@@ -15,7 +15,7 @@ module Smith
15
15
  end
16
16
 
17
17
  def execute(agent_name, **opts)
18
- raise WorkflowError, "transition cannot declare both execute and compute/run" if @deterministic_block
18
+ validate_execute_conflicts!
19
19
 
20
20
  @agent_name = agent_name
21
21
  @agent_opts = opts
@@ -30,7 +30,7 @@ module Smith
30
30
  end
31
31
 
32
32
  def route(agent_name, routes:, confidence_threshold:, fallback:)
33
- raise WorkflowError, "transition cannot declare both route and compute/run" if @deterministic_block
33
+ validate_route_conflicts!
34
34
 
35
35
  @agent_name = agent_name
36
36
  @router_config = { routes: routes, confidence_threshold: confidence_threshold, fallback: fallback }
@@ -39,9 +39,16 @@ module Smith
39
39
  def workflow(klass)
40
40
  raise WorkflowError, "workflow binding must be a Class" unless klass.is_a?(Class)
41
41
  raise WorkflowError, "workflow binding must be a Smith::Workflow subclass" unless klass < Workflow
42
- raise WorkflowError, "transition cannot declare both workflow and execute" if @agent_name && !@router_config
43
- raise WorkflowError, "transition cannot declare both workflow and route" if @router_config
44
- raise WorkflowError, "transition cannot declare both workflow and compute/run" if @deterministic_block
42
+
43
+ validate_conflicts!(
44
+ "workflow",
45
+ [
46
+ ["execute", @agent_name && !@router_config],
47
+ ["route", @router_config],
48
+ ["compute/run", @deterministic_block],
49
+ ["fan_out", @fanout_config]
50
+ ]
51
+ )
45
52
 
46
53
  @workflow_class = klass
47
54
  end
@@ -77,6 +84,25 @@ module Smith
77
84
  @orchestrator_config = opts
78
85
  end
79
86
 
87
+ def fan_out(branches:)
88
+ validate_fanout_conflicts!
89
+
90
+ @fanout_config = { branches: normalize_fanout_branches!(branches) }
91
+ end
92
+ alias fanout fan_out
93
+
94
+ def retry_on(*error_classes, attempts:, backoff: 0, max_delay: nil, jitter: 0)
95
+ validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
96
+
97
+ @retry_config = {
98
+ error_classes: error_classes.freeze,
99
+ attempts: attempts,
100
+ backoff: Float(backoff),
101
+ max_delay: max_delay.nil? ? nil : Float(max_delay),
102
+ jitter: Float(jitter)
103
+ }.freeze
104
+ end
105
+
80
106
  %i[compute run].each do |method_name|
81
107
  define_method(method_name) do |&block|
82
108
  validate_deterministic_conflicts!
@@ -95,6 +121,10 @@ module Smith
95
121
  !@orchestrator_config.nil?
96
122
  end
97
123
 
124
+ def fanout?
125
+ !@fanout_config.nil?
126
+ end
127
+
98
128
  def optimized?
99
129
  !@optimization_config.nil?
100
130
  end
@@ -113,28 +143,148 @@ module Smith
113
143
 
114
144
  private
115
145
 
146
+ def validate_execute_conflicts!
147
+ validate_conflicts!(
148
+ "execute",
149
+ [
150
+ ["compute/run", @deterministic_block],
151
+ ["fan_out", @fanout_config]
152
+ ]
153
+ )
154
+ end
155
+
156
+ def validate_route_conflicts!
157
+ validate_conflicts!(
158
+ "route",
159
+ [
160
+ ["compute/run", @deterministic_block],
161
+ ["fan_out", @fanout_config]
162
+ ]
163
+ )
164
+ end
165
+
116
166
  def validate_deterministic_conflicts!
117
- raise WorkflowError, "transition cannot declare both compute/run and execute" if @agent_name && !@router_config
118
- raise WorkflowError, "transition cannot declare both compute/run and route" if @router_config
119
- raise WorkflowError, "transition cannot declare both compute/run and workflow" if @workflow_class
120
- raise WorkflowError, "transition cannot declare both compute/run and optimize" if @optimization_config
121
- raise WorkflowError, "transition cannot declare both compute/run and orchestrate" if @orchestrator_config
167
+ validate_conflicts!(
168
+ "compute/run",
169
+ [
170
+ ["execute", @agent_name && !@router_config],
171
+ ["route", @router_config],
172
+ ["workflow", @workflow_class],
173
+ ["optimize", @optimization_config],
174
+ ["orchestrate", @orchestrator_config],
175
+ ["fan_out", @fanout_config]
176
+ ]
177
+ )
122
178
  raise WorkflowError, "transition cannot declare both compute and run" if @deterministic_block
123
179
  end
124
180
 
125
181
  def validate_optimize_conflicts!
126
- raise WorkflowError, "transition cannot declare both optimize and execute" if @agent_name && !@router_config
127
- raise WorkflowError, "transition cannot declare both optimize and route" if @router_config
128
- raise WorkflowError, "transition cannot declare both optimize and workflow" if @workflow_class
129
- raise WorkflowError, "transition cannot declare both optimize and compute/run" if @deterministic_block
182
+ validate_conflicts!(
183
+ "optimize",
184
+ [
185
+ ["execute", @agent_name && !@router_config],
186
+ ["route", @router_config],
187
+ ["workflow", @workflow_class],
188
+ ["compute/run", @deterministic_block],
189
+ ["fan_out", @fanout_config]
190
+ ]
191
+ )
130
192
  end
131
193
 
132
194
  def validate_orchestrate_conflicts!
133
- raise WorkflowError, "transition cannot declare both orchestrate and execute" if @agent_name && !@router_config
134
- raise WorkflowError, "transition cannot declare both orchestrate and route" if @router_config
135
- raise WorkflowError, "transition cannot declare both orchestrate and workflow" if @workflow_class
136
- raise WorkflowError, "transition cannot declare both orchestrate and optimize" if @optimization_config
137
- raise WorkflowError, "transition cannot declare both orchestrate and compute/run" if @deterministic_block
195
+ validate_conflicts!(
196
+ "orchestrate",
197
+ [
198
+ ["execute", @agent_name && !@router_config],
199
+ ["route", @router_config],
200
+ ["workflow", @workflow_class],
201
+ ["optimize", @optimization_config],
202
+ ["compute/run", @deterministic_block],
203
+ ["fan_out", @fanout_config]
204
+ ]
205
+ )
206
+ end
207
+
208
+ def validate_fanout_conflicts!
209
+ validate_conflicts!(
210
+ "fan_out",
211
+ [
212
+ ["execute", @agent_name && !@router_config],
213
+ ["route", @router_config],
214
+ ["workflow", @workflow_class],
215
+ ["optimize", @optimization_config],
216
+ ["orchestrate", @orchestrator_config],
217
+ ["compute/run", @deterministic_block]
218
+ ]
219
+ )
220
+ end
221
+
222
+ def validate_conflicts!(primitive, conflicts)
223
+ conflicts.each do |other, present|
224
+ raise WorkflowError, "transition cannot declare both #{primitive} and #{other}" if present
225
+ end
226
+ end
227
+
228
+ def normalize_fanout_branches!(branches)
229
+ raise WorkflowError, "fan_out branches must be a Hash" unless branches.is_a?(Hash)
230
+ raise WorkflowError, "fan_out requires at least one branch" if branches.empty?
231
+
232
+ normalized = branches.each_with_object({}) do |(branch_key, agent_name), map|
233
+ key = normalize_fanout_branch_key!(branch_key)
234
+ agent = normalize_fanout_agent_name!(agent_name, key)
235
+ raise WorkflowError, "fan_out branch #{key.inspect} is duplicated" if map.key?(key)
236
+
237
+ map[key] = agent
238
+ end
239
+
240
+ validate_distinct_fanout_agents!(normalized)
241
+ normalized.freeze
242
+ end
243
+
244
+ def normalize_fanout_branch_key!(branch_key)
245
+ key = branch_key.to_s.strip
246
+ raise WorkflowError, "fan_out branch keys must not be blank" if key.empty?
247
+
248
+ key.to_sym
249
+ end
250
+
251
+ def normalize_fanout_agent_name!(agent_name, branch_key)
252
+ value = agent_name.to_s.strip
253
+ raise WorkflowError, "fan_out branch #{branch_key.inspect} must declare an agent" if value.empty?
254
+
255
+ value.to_sym
256
+ end
257
+
258
+ def validate_distinct_fanout_agents!(branches)
259
+ duplicates = branches.values.tally.select { |_agent, count| count > 1 }.keys
260
+ return if duplicates.empty?
261
+
262
+ raise WorkflowError, "fan_out branch agents must be distinct: #{duplicates.map(&:inspect).join(", ")}"
263
+ end
264
+
265
+ def validate_retry_controls!(error_classes, attempts:, backoff:, max_delay:, jitter:)
266
+ unless attempts.is_a?(Integer) && attempts.positive?
267
+ raise WorkflowError, "retry_on attempts must be a positive integer"
268
+ end
269
+
270
+ error_classes.each do |error_class|
271
+ next if error_class.is_a?(Class) && error_class <= StandardError
272
+
273
+ raise WorkflowError, "retry_on error classes must inherit from StandardError"
274
+ end
275
+
276
+ validate_non_negative_numeric!(:backoff, backoff)
277
+ validate_non_negative_numeric!(:jitter, jitter)
278
+ validate_non_negative_numeric!(:max_delay, max_delay) unless max_delay.nil?
279
+ end
280
+
281
+ def validate_non_negative_numeric!(name, value)
282
+ numeric = Float(value)
283
+ return if numeric >= 0.0
284
+
285
+ raise WorkflowError, "retry_on #{name} must be non-negative"
286
+ rescue TypeError, ArgumentError
287
+ raise WorkflowError, "retry_on #{name} must be numeric"
138
288
  end
139
289
 
140
290
  def validate_orchestrate_controls!(opts)
@@ -177,7 +327,7 @@ module Smith
177
327
  raise WorkflowError, "optimize max_rounds must be a positive integer"
178
328
  end
179
329
 
180
- VALID_EXIT_MODES = [:raise, :return_last].freeze
330
+ VALID_EXIT_MODES = %i[raise return_last].freeze
181
331
  private_constant :VALID_EXIT_MODES
182
332
 
183
333
  def validate_optimize_exit_modes!(on_exhaustion:, on_converged:, on_threshold:)
data/lib/smith.rb CHANGED
@@ -243,6 +243,8 @@ require_relative "smith/workflow/nested_execution"
243
243
  require_relative "smith/workflow/evaluator_optimizer"
244
244
  require_relative "smith/workflow/orchestrator_worker"
245
245
  require_relative "smith/workflow/parallel_execution"
246
+ require_relative "smith/workflow/fanout_execution"
247
+ require_relative "smith/workflow/retry_execution"
246
248
  require_relative "smith/workflow/deterministic_step"
247
249
  require_relative "smith/workflow/deterministic_execution"
248
250
  require_relative "smith/workflow/execution"
@@ -252,6 +254,7 @@ require_relative "smith/workflow/execution_frame"
252
254
  require_relative "smith/workflow/pipeline"
253
255
  require_relative "smith/workflow/router"
254
256
  require_relative "smith/workflow/parallel"
257
+ require_relative "smith/workflow/parallel/cancellation"
255
258
 
256
259
  # Conditional Rails integration
257
260
  require_relative "smith/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smith-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Ralak
@@ -219,6 +219,7 @@ files:
219
219
  - lib/smith/workflow/event_integration.rb
220
220
  - lib/smith/workflow/execution.rb
221
221
  - lib/smith/workflow/execution_frame.rb
222
+ - lib/smith/workflow/fanout_execution.rb
222
223
  - lib/smith/workflow/graph.rb
223
224
  - lib/smith/workflow/graph/diagnostic.rb
224
225
  - lib/smith/workflow/graph/metrics.rb
@@ -236,9 +237,11 @@ files:
236
237
  - lib/smith/workflow/nested_execution.rb
237
238
  - lib/smith/workflow/orchestrator_worker.rb
238
239
  - lib/smith/workflow/parallel.rb
240
+ - lib/smith/workflow/parallel/cancellation.rb
239
241
  - lib/smith/workflow/parallel_execution.rb
240
242
  - lib/smith/workflow/persistence.rb
241
243
  - lib/smith/workflow/pipeline.rb
244
+ - lib/smith/workflow/retry_execution.rb
242
245
  - lib/smith/workflow/router.rb
243
246
  - lib/smith/workflow/transition.rb
244
247
  - script/profile_tool_results.rb