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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6e0cbabc0bd3db4447206b0326a96996b5b3a6b46ec82e0533298f4074b0659
4
- data.tar.gz: 1e4f9346f46675db971627676f5d85621120d3387e95fab5258ef5de88f22939
3
+ metadata.gz: 1c5a7f554819eb342aa2284fb010eba8126f22fba1348683a36fbc4fa9d5383f
4
+ data.tar.gz: b72e10a5415340c5b337751c0cc92f2f59ed0ddb62850e03746df217be374721
5
5
  SHA512:
6
- metadata.gz: c91ea9dfb9d21c284278a46a12380b94aba48091370a9db358ea2a5ddb96a21b1a18a453d6d0b431f2839a153a20e5d93bd9a611573f93c99efb59b9e48a21dd
7
- data.tar.gz: f75d0d058c722e640c1299cb62beaafb7a3051f13d786460b289187a7c4a15c5e32aa2f2f5d8b68369d5b5d1b8d6e479147bbf2beb668a2273e740ef7a21f882
6
+ metadata.gz: 75d149a61196f2c0cd22748ff6d5a6c4530bc9d91c61b163c027e44d555b9fd0f1ca5ef0a283c6025d6906b6d984923265fba05190fa3da37a60cfe864e5f6f8
7
+ data.tar.gz: 906bc56189df0f84ad62cb643ed8141a05780a328bf9a3770db55c575d71db86b94ad39e91517ba0e2d76f05942f84358dc234b2e685ad0701dba059bed65d36
data/CHANGELOG.md CHANGED
@@ -8,6 +8,58 @@ 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
+
47
+ ## [0.4.1] - 2026-06-28
48
+
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.
50
+
51
+ ### Added
52
+
53
+ - `Smith::Workflow.graph` — returns a read-only inspection object for a workflow class.
54
+ - `Smith::Workflow.validate_graph` — returns a structured report with validity status, diagnostics, suggestions, transition snapshots, and graph metrics.
55
+ - Pre-runtime graph diagnostics for missing initial states, undefined transition states, unresolved `on_success` / `on_failure` targets, unresolved router route/fallback targets, target-state mismatch warnings, and unreachable-transition warnings.
56
+ - Transition snapshots that preserve declared names exactly and expose `name`, `from`, `to`, `kind`, success/failure targets, router routes, and router fallback.
57
+
58
+ ### Test coverage
59
+
60
+ - Default suite: 862 examples, 0 failures.
61
+ - Touched Ruby files: 17 files inspected by RuboCop, 0 offenses.
62
+
11
63
  ## [0.4.0] - 2026-06-24
12
64
 
13
65
  Two more host-ergonomic primitives that close the deferred-from-0.3.0 backlog: `Workflow.stuck_for?` for liveness probing and `Context.persist :auto` for write-tracked context persistence. Both are purely additive.
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. |
@@ -94,6 +103,21 @@ end
94
103
 
95
104
  The full pattern guide with working examples for each lives in [`docs/PATTERNS.md`](docs/PATTERNS.md).
96
105
 
106
+ ## Workflow Graph Inspection
107
+
108
+ Smith can inspect a workflow's declared graph without running agents or advancing state. This is useful for host apps that want to render, lint, or cache a workflow shape before execution.
109
+
110
+ ```ruby
111
+ report = ReplyWorkflow.validate_graph
112
+
113
+ report.valid? # => true
114
+ report.transitions # => read-only transition snapshots
115
+ report.diagnostics # => errors and warnings for missing states or routes
116
+ report.metrics # => state, transition, reachability, and terminal-state counts
117
+ ```
118
+
119
+ Graph inspection is static and diagnostic-only. Runtime execution, persistence, progress projection, retries, and recovery remain host-owned concerns.
120
+
97
121
  ## Configuration
98
122
 
99
123
  ```ruby
@@ -215,6 +239,19 @@ Smith::Errors.retryable_classes
215
239
  # => [Smith::AgentError, Smith::DeadlineExceeded] (for ActiveJob retry_on)
216
240
  ```
217
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
+
218
255
  ## Development
219
256
 
220
257
  ```bash
@@ -223,4 +260,5 @@ bundle exec rspec
223
260
  bundle exec rubocop
224
261
  ```
225
262
 
226
- 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.0"
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
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Diagnostic
7
+ attr_reader :severity, :code, :message, :state, :transition, :target, :suggestion
8
+
9
+ def initialize(**attributes)
10
+ @severity = attributes.fetch(:severity)
11
+ @code = attributes.fetch(:code)
12
+ @message = attributes.fetch(:message)
13
+ @state = attributes[:state]
14
+ @transition = attributes[:transition]
15
+ @target = attributes[:target]
16
+ @suggestion = attributes[:suggestion]
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ severity: severity,
22
+ code: code,
23
+ message: message,
24
+ state: state,
25
+ transition: transition,
26
+ target: target,
27
+ suggestion: suggestion
28
+ }.compact
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Metrics
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_h
15
+ {
16
+ states_count: graph.states.length,
17
+ transitions_count: graph.transitions.length,
18
+ reachable_transitions_count: reachable_transition_names.length,
19
+ terminal_states: terminal_states
20
+ }
21
+ end
22
+
23
+ private
24
+
25
+ def terminal_states
26
+ graph.states.select do |state|
27
+ graph.transitions.values.none? { |transition| transition.from == state }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ class Graph
6
+ class Reachability
7
+ attr_reader :graph
8
+
9
+ def initialize(graph)
10
+ @graph = graph
11
+ end
12
+
13
+ def transition_names
14
+ return [] unless graph.initial_state
15
+
16
+ reset_walk
17
+ drain_queue
18
+ @seen_transitions.keys
19
+ end
20
+
21
+ private
22
+
23
+ def reset_walk
24
+ @seen_states = { graph.initial_state => true }
25
+ @seen_transitions = {}
26
+ @queue = [graph.initial_state]
27
+ end
28
+
29
+ def drain_queue
30
+ transitions_from(@queue.shift).each { |transition| visit_transition(transition) } until @queue.empty?
31
+ end
32
+
33
+ def visit_transition(transition)
34
+ return if @seen_transitions.key?(transition.name)
35
+
36
+ @seen_transitions[transition.name] = true
37
+ enqueue_state(transition.to)
38
+ Targets.for(transition).each { |target_name| visit_named_transition(target_name) }
39
+ end
40
+
41
+ def visit_named_transition(target_name)
42
+ target = graph.transitions[target_name]
43
+ visit_transition(target) if target
44
+ end
45
+
46
+ def enqueue_state(state)
47
+ return if state.nil? || @seen_states.key?(state)
48
+
49
+ @seen_states[state] = true
50
+ @queue << state
51
+ end
52
+
53
+ def transitions_from(state)
54
+ graph.transitions.values.select { |transition| transition.from == state }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end