smith-agents 0.5.0 → 0.6.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +83 -0
  4. data/lib/smith/agent.rb +13 -0
  5. data/lib/smith/errors.rb +4 -0
  6. data/lib/smith/version.rb +2 -2
  7. data/lib/smith/workflow/composite/branch.rb +93 -0
  8. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  9. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  10. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  11. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  12. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  13. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  14. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  15. data/lib/smith/workflow/composite/contract.rb +100 -0
  16. data/lib/smith/workflow/composite/effects.rb +140 -0
  17. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  18. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  19. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  20. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  21. data/lib/smith/workflow/composite/enums.rb +27 -0
  22. data/lib/smith/workflow/composite/error.rb +54 -0
  23. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  24. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  25. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  26. data/lib/smith/workflow/composite/input.rb +53 -0
  27. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  28. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  29. data/lib/smith/workflow/composite/payload.rb +128 -0
  30. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  31. data/lib/smith/workflow/composite/plan.rb +130 -0
  32. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  33. data/lib/smith/workflow/composite/planner.rb +53 -0
  34. data/lib/smith/workflow/composite/preparation.rb +27 -0
  35. data/lib/smith/workflow/composite/reducer.rb +133 -0
  36. data/lib/smith/workflow/composite/reduction.rb +31 -0
  37. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  38. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  39. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  40. data/lib/smith/workflow/execution.rb +16 -16
  41. data/lib/smith/workflow/fanout_execution.rb +19 -18
  42. data/lib/smith/workflow/message_value_normalizer.rb +11 -10
  43. data/lib/smith/workflow/parallel_execution.rb +41 -13
  44. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  45. data/lib/smith/workflow/prepared_step_execution_authorization.rb +14 -29
  46. data/lib/smith/workflow/prepared_step_execution_scope.rb +81 -15
  47. data/lib/smith/workflow/process_local.rb +33 -0
  48. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  49. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  50. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  51. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  52. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  53. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  54. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  55. data/lib/smith/workflow/split_step_persistence/execution.rb +7 -53
  56. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +41 -21
  57. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  58. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +5 -0
  59. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +17 -3
  60. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  61. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +17 -5
  62. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +8 -0
  63. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +21 -5
  64. data/lib/smith/workflow/split_step_persistence.rb +14 -0
  65. data/lib/smith/workflow/step_context.rb +46 -0
  66. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  67. data/lib/smith/workflow/transition.rb +16 -1
  68. data/lib/smith/workflow.rb +21 -1
  69. data/lib/smith.rb +3 -0
  70. metadata +46 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f0194d534f9aa31c42dc14a0bd92a092f0088b3ac138ffe65060b00c6c564a0
4
- data.tar.gz: 314f07b2e7c51f2372fec28cf7d495672ce38f4ba1f09a9af4cccba6f9cefd17
3
+ metadata.gz: 988c5dc6e794da7b78889dc17db5801c9ad8ebf99a008a0b043bdc56e7115126
4
+ data.tar.gz: ad4ecd254dd213b16e2b5e27730fed50fdd6491b86d45d5d76f40ba620a0bc33
5
5
  SHA512:
6
- metadata.gz: f75713ef01aa104f3add7f6804dc8f77061ad12ea787598a16dce09bb0b08c23e256f35b346d4f3e53c99b86a0bdb5603ca6f7236dc66a1c60130a16ab9180a5
7
- data.tar.gz: 28953ec750b620efff05055af313031eb24e97f6591dea4ea04b029d8c71d0a72de37d2aecb6ec78c532f73d3301d854b6f853bd26515abd98f93a5fe4668edb
6
+ metadata.gz: e439e1c6cc4cae2feffdb3a258e9abe94dd6f90a7641e76e36784921f09864d53d4d96616823ef2bfb43c56aa5cc9082eb689fffd4fc1464fa51e752f724139f
7
+ data.tar.gz: 6d7bbf59a8b7c5297078af12b08a2b092287c5def28df0dac69cc10553e308abb238512e275166e372b08dcd155b6583e898d91b25b89ef4303ea5da09476eb4
data/CHANGELOG.md CHANGED
@@ -6,6 +6,78 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ### Fixed
10
+
11
+ - Persist a composite transition's execution namespace with its prepared
12
+ split-step snapshot so prepared- and dispatch-state recovery reproduce the
13
+ exact same plan.
14
+ - Pin composite namespace preparation to Smith-owned implementations so
15
+ subclass method collisions cannot bypass durable plan identity, and release
16
+ the preparation claim cleanly when namespace generation fails.
17
+
18
+ ## [0.6.0] - 2026-07-20
19
+
20
+ ### Added
21
+
22
+ - Add a host-neutral durable composite lifecycle for same-agent parallel and
23
+ heterogeneous fan-out transitions. Smith emits immutable bounded plans and
24
+ inputs, executes one authorized branch through captured agent bindings, and
25
+ deterministically reduces a complete ordered outcome set through the normal
26
+ transition completion and failure paths.
27
+ - Add exact transport contracts for ordered branch descriptors, redacted branch
28
+ failures, usage/tool/budget effects, and host-committed primary failure
29
+ selection. Composite branch retries remain explicitly unsupported; hosts own
30
+ scheduling, persistence, claims, fences, and incomplete-branch resumption.
31
+ - Add compact selected-branch execution envelopes and stable host-declared agent
32
+ execution identities so workers do not receive the full plan and registry
33
+ replacement after planning fails closed.
34
+ - Add scoped prepare, selected-branch execution, and reduction entry points so
35
+ process-local authority never crosses the supported public composite API.
36
+
37
+ ### Changed
38
+
39
+ - Bump `Smith::EXECUTION_SEMANTICS_VERSION` to `2` so hosts invalidate generated
40
+ executable definitions when adopting the composite lifecycle.
41
+ - Canonicalize aggregate branch output to the same JSON-compatible shape before
42
+ and after persistence.
43
+
44
+ ### Fixed
45
+
46
+ - Preserve complete workflow, agent, and branch execution context across
47
+ nested parallel and heterogeneous fan-out work without leaking thread- or
48
+ Fiber-local Smith state.
49
+ - Keep ordinary subclass setup and teardown extension points intact while
50
+ prepared execution seals framework-owned branch dispatch against subclass
51
+ replacement.
52
+ - Restrict prepared execution authority to the exact coordinator or registered
53
+ branch Fiber, reject copied or serialized process-local authority, and make
54
+ activation, cleanup, and context restoration safe against asynchronous Ruby
55
+ thread interruption.
56
+ - Keep composite planning and full-run validation linear in branch count, make
57
+ individual branch binding capture and validation constant-time in branch
58
+ count, reject the hard transport ceiling before descriptor allocation, and
59
+ incrementally enforce cumulative effect/output envelopes before consuming
60
+ execution authority.
61
+ - Isolate decimal aggregation from host `BigDecimal` precision, reject aggregate
62
+ usage overflow, and preserve composite branch identity and retry
63
+ classification across persistence.
64
+ - Bind one artifact execution namespace into every branch and the reducer,
65
+ reject aggregate budget overrun and known usage replay before consuming
66
+ authority, and validate cumulative persisted token/cost accounting.
67
+ - Enforce complete canonical payload fields and encoded JSON byte limits before
68
+ returning transport values.
69
+ - Allocate disjoint branch budget envelopes without exceeding the parent,
70
+ reconcile usage against branch-local token/cost consumption, bind branch
71
+ authority to one exact execution envelope, and prevent subclass collisions
72
+ from bypassing durable dispatch verification.
73
+ - Reject forged retryability, untrusted error metadata, and unknown transport
74
+ keys without symbol interning; validate scalar and replay failures before
75
+ snapshotting tool effects.
76
+ - Reject unknown transport enum values without symbol interning, reject corrupt
77
+ persisted branch-failure evidence instead of inventing defaults, enforce
78
+ cumulative encoded-effect bounds while streaming outcomes, and close active
79
+ branch authority before asynchronous interruption can escape a scoped call.
80
+
9
81
  ## [0.5.0] - 2026-07-19
10
82
 
11
83
  This release contains intentional pre-1.0 public contract tightening and is a
data/README.md CHANGED
@@ -260,6 +260,89 @@ During authorized execution, Smith also seals its private execution path against
260
260
  workflow subclass method-name collisions, including nested child workflows.
261
261
  Ordinary non-authorized runs retain normal Ruby override behavior.
262
262
 
263
+ For `parallel: true` and `fan_out` transitions, a host can split one confirmed
264
+ dispatch into independently scheduled branch operations without invoking agent
265
+ classes outside Smith:
266
+
267
+ ```ruby
268
+ preparation = workflow.prepare_composite_step!
269
+
270
+ # Persist preparation.plan and preparation.input in host-owned records.
271
+ # Each worker recovers the same confirmed dispatch and receives one descriptor.
272
+ branch = preparation.plan.branches.fetch(0)
273
+ execution = preparation.plan.execution_for(branch)
274
+ outcome = branch_workflow.execute_prepared_composite_branch!(
275
+ execution:,
276
+ input: preparation.input
277
+ )
278
+
279
+ # After every branch has a terminal outcome, use another fresh recovery to
280
+ # complete the Smith transition exactly once.
281
+ result = reducer_workflow.reduce_prepared_composite_step!(
282
+ plan: preparation.plan,
283
+ input: preparation.input,
284
+ outcomes: ordered_or_unordered_complete_outcome_set,
285
+ primary_failure: host_committed_failed_branch_key
286
+ )
287
+ ```
288
+
289
+ Plans bind the exact prepared dispatch, host-supplied complete workflow
290
+ `definition_digest`, execution-semantics version, prepared input digest, budget
291
+ baseline, one shared artifact execution namespace, ordered branch identities,
292
+ and explicit policies. Each plan, input, outcome, or effect payload is
293
+ canonical, immutable, bounded to 1 MiB after JSON encoding, rejects missing or
294
+ unknown fields, and redacts exception messages and backtraces.
295
+ Reduction additionally bounds cumulative effects to 1 MiB and final aggregate
296
+ output to 4 MiB before consuming execution authority. Reduction accepts either
297
+ an `Array` or an `Enumerator`, validates each outcome incrementally, and stops
298
+ as soon as a cumulative bound is crossed. The transport hard limit is 10,000
299
+ branches; every recovered worker also enforces the current
300
+ `Smith.config.parallel_branch_limit` before work.
301
+
302
+ Planning is `O(B * D)`, where `D` is the bounded number of budget dimensions.
303
+ The coordinator derives a compact selected-branch execution envelope in
304
+ `O(D)` without copying the full branch collection. The scoped branch execution
305
+ entry point captures and validates one selected binding in `O(D)` and constant
306
+ time with respect to total branch count; the reducer validates the complete plan
307
+ once. Reduction is `O(B + P log P)` in
308
+ the worst case because canonical nested Hash ownership sorts keys, and uses
309
+ `O(B + P)` bounded space.
310
+ Smith does not persist plans or outcomes, select scheduling slots, claim work,
311
+ choose an uncertain-outcome policy, or infer the primary failure. The host
312
+ durably owns those decisions, gives each branch and reduction a separately
313
+ recovered workflow instance, and must pass a complete outcome set. Transport or
314
+ encoding faults become typed branch failures when Smith can preserve accounting
315
+ evidence; otherwise the host must treat the worker result as uncertain and must
316
+ not infer safe retry. Resume policy is incomplete branches only; per-branch
317
+ retry is intentionally unsupported in this contract version.
318
+ Composite payload digests prove canonical content integrity, not worker
319
+ authenticity. Before reduction, the host must authenticate every outcome against
320
+ its own claimed operation identity and current fencing token; those durable
321
+ receipts remain outside Smith.
322
+
323
+ The supported composite API is the three scoped workflow methods above plus the
324
+ immutable transport values they return or accept: `Preparation`, `Plan`,
325
+ `Input`, `Branch`, `BranchExecution`, `BranchOutcome`, `BranchFailure`, `Error`,
326
+ `Effects`, and `Reduction`. Other constants under `Workflow::Composite` are
327
+ implementation collaborators, not extension points or compatibility promises.
328
+
329
+ Every agent used by this durable composite boundary must declare a stable
330
+ host-computed SHA-256 execution identity. The identity represents the exact
331
+ sealed executable asset the host intends to dispatch; Smith records it in the
332
+ branch descriptor and rejects registry replacement after planning. Identities
333
+ are deliberately not inherited because a subclass is a distinct executable
334
+ implementation.
335
+
336
+ ```ruby
337
+ class Researcher < Smith::Agent
338
+ execution_identity Digest::SHA256.hexdigest(generated_asset_manifest)
339
+ register_as :researcher, publish: false
340
+ end
341
+
342
+ Researcher.freeze
343
+ Researcher.publish_registration!
344
+ ```
345
+
263
346
  Hosts that generate agent classes dynamically may call
264
347
  `register_as :name, publish: false` while constructing the class. This records
265
348
  the class identity without exposing it through `Smith::Agent::Registry`. After
data/lib/smith/agent.rb CHANGED
@@ -4,6 +4,9 @@ require "ruby_llm"
4
4
 
5
5
  module Smith
6
6
  class Agent < RubyLLM::Agent
7
+ EXECUTION_IDENTITY_UNSET = Object.new.freeze
8
+ private_constant :EXECUTION_IDENTITY_UNSET
9
+
7
10
  # Reserved input names auto-injected by the normalizer into
8
11
  # runtime_context. User-side `inputs :name` calls cannot redeclare
9
12
  # these names; the override raises Smith::AgentError if they try.
@@ -20,9 +23,19 @@ module Smith
20
23
  subclass.instance_variable_set(:@data_volume, @data_volume)
21
24
  subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup)
22
25
  subclass.instance_variable_set(:@model_block, @model_block)
26
+ subclass.instance_variable_set(:@execution_identity, nil)
23
27
  subclass.instance_variable_set(:@registered_name, nil)
24
28
  end
25
29
 
30
+ def execution_identity(value = EXECUTION_IDENTITY_UNSET)
31
+ return @execution_identity if value.equal?(EXECUTION_IDENTITY_UNSET)
32
+ unless value.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(value)
33
+ raise ArgumentError, "execution_identity must be a lowercase SHA-256 hex digest"
34
+ end
35
+
36
+ @execution_identity = value.dup.freeze
37
+ end
38
+
26
39
  def budget(**opts)
27
40
  return @budget_config if opts.empty?
28
41
 
data/lib/smith/errors.rb CHANGED
@@ -15,6 +15,10 @@ module Smith
15
15
  def self.retryable?(error)
16
16
  return false if error.nil?
17
17
 
18
+ composite_failure = defined?(Smith::Workflow::Composite::BranchFailure) &&
19
+ error.is_a?(Smith::Workflow::Composite::BranchFailure)
20
+ return false if composite_failure
21
+
18
22
  case error
19
23
  when Smith::DeterministicStepFailure, Smith::ToolGuardrailFailed
20
24
  error.retryable == true
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.5.0"
5
- EXECUTION_SEMANTICS_VERSION = "1"
4
+ VERSION = "0.6.1"
5
+ EXECUTION_SEMANTICS_VERSION = "2"
6
6
  end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../message_value_normalizer"
5
+ require_relative "../prepared_step"
6
+ require_relative "payload"
7
+ require_relative "payload_digest"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module Composite
12
+ class Branch < Payload
13
+ MAX_BUDGET_DIMENSIONS = 128
14
+ HASH_LENGTH = Hash.instance_method(:length)
15
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
16
+ private_constant :HASH_LENGTH, :HASH_EACH_PAIR
17
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
18
+ private_constant :OwnedString
19
+
20
+ attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
21
+ attribute :key, OwnedString.constrained(min_size: 1, max_size: 256)
22
+ attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
23
+ attribute :binding_identity, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
24
+ attribute :budget, Types::Hash
25
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
26
+
27
+ def self.build(ordinal:, key:, agent:, binding_identity:, budget:)
28
+ attributes = {
29
+ ordinal:,
30
+ key: key.to_s,
31
+ agent: agent.to_s,
32
+ binding_identity:,
33
+ budget: normalize_budget(budget)
34
+ }
35
+ new(attributes.merge(digest: PayloadDigest.call(attributes)))
36
+ end
37
+
38
+ def initialize(attributes)
39
+ owned = self.class.normalize_attributes(attributes)
40
+ owned[:budget] = self.class.send(:normalize_budget, owned[:budget])
41
+ super(owned)
42
+ expected = PayloadDigest.call(to_h.except(:digest))
43
+ raise ArgumentError, "composite branch digest does not match" unless digest == expected
44
+ end
45
+
46
+ class << self
47
+ private
48
+
49
+ def normalize_budget(value)
50
+ normalized = normalized_budget(value)
51
+ validate_budget_dimensions!(normalized)
52
+ copy_budget(normalized)
53
+ end
54
+
55
+ def normalized_budget(value)
56
+ normalized = MessageValueNormalizer.new(value, label: "composite branch budget").call
57
+ raise ArgumentError, "composite branch budget must be a Hash" unless normalized.is_a?(Hash)
58
+
59
+ normalized
60
+ end
61
+
62
+ def validate_budget_dimensions!(budget)
63
+ return if HASH_LENGTH.bind_call(budget) <= MAX_BUDGET_DIMENSIONS
64
+
65
+ raise ArgumentError, "composite branch budget contains too many dimensions"
66
+ end
67
+
68
+ def copy_budget(normalized)
69
+ budget = {}
70
+ HASH_EACH_PAIR.bind_call(normalized) do |name, amount|
71
+ validate_budget_dimension!(name)
72
+ validate_budget_amount!(amount)
73
+ budget[name.freeze] = amount
74
+ end
75
+ budget.freeze
76
+ end
77
+
78
+ def validate_budget_dimension!(name)
79
+ return if name.length.between?(1, 256)
80
+
81
+ raise ArgumentError, "composite branch budget dimensions must be bounded non-empty strings"
82
+ end
83
+
84
+ def validate_budget_amount!(amount)
85
+ return if amount.is_a?(Numeric) && amount.finite? && amount >= 0
86
+
87
+ raise ArgumentError, "composite branch budget values must be finite non-negative numbers"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "budget_allocator"
4
+ require_relative "plan"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ module BranchBudgetContract
10
+ private
11
+
12
+ def validate_composite_branch_count!(count)
13
+ Plan.validate_branch_count!(count)
14
+ end
15
+
16
+ def composite_budget_allocator(branch_count)
17
+ return unless @ledger
18
+
19
+ BudgetAllocator.new(ledger: @ledger, branch_count:)
20
+ end
21
+
22
+ def composite_branch_budget(allocator, ordinal, agent_budget)
23
+ return {} unless allocator
24
+
25
+ caps = @ledger.limits.each_key.with_object({}) do |dimension, result|
26
+ cap = agent_cap_for_dimension(dimension, agent_budget)
27
+ result[dimension] = cap unless cap.nil?
28
+ end
29
+ allocator.call(ordinal:, caps:)
30
+ end
31
+
32
+ def composite_binding_identity!(agent_class, agent)
33
+ identity = agent_class&.execution_identity
34
+ return identity if identity
35
+
36
+ raise WorkflowError, "durable composite agent :#{agent} requires an execution_identity"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parallel"
4
+ require_relative "branch"
5
+ require_relative "branch_budget_contract"
6
+ require_relative "fanout_branch_contract"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ module BranchContract
12
+ include BranchBudgetContract
13
+ include FanoutBranchContract
14
+
15
+ private
16
+
17
+ def composite_branch_specs(authorization, transition, branch_count: nil)
18
+ return parallel_branch_specs(authorization, transition, branch_count:) if transition.parallel?
19
+
20
+ fanout_branch_specs(authorization, transition)
21
+ end
22
+
23
+ def parallel_branch_specs(authorization, transition, branch_count: nil)
24
+ validate_planned_parallel_count!(transition, branch_count) if branch_count
25
+ count = branch_count || Parallel.resolve_branch_count(transition, @context)
26
+ validate_composite_branch_count!(count)
27
+ agent = transition.agent_name
28
+ agent_class = captured_agent(authorization, transition, agent, :agent)
29
+ allocator = composite_budget_allocator(count)
30
+ Array.new(count) do |ordinal|
31
+ parallel_branch_spec(agent, agent_class, allocator, ordinal).merge(key: ordinal.to_s)
32
+ end.freeze
33
+ end
34
+
35
+ def parallel_branch_spec(agent, agent_class, allocator, ordinal)
36
+ {
37
+ agent: agent.to_s,
38
+ binding_identity: composite_binding_identity!(agent_class, agent),
39
+ budget: composite_branch_budget(allocator, ordinal, agent_class&.budget)
40
+ }.freeze
41
+ end
42
+
43
+ def validate_planned_parallel_count!(transition, branch_count)
44
+ Parallel.validate_branch_count!(branch_count)
45
+ configured = transition.agent_opts[:count]
46
+ return if configured.respond_to?(:call)
47
+ return if branch_count == (configured || 1)
48
+
49
+ raise WorkflowError, "composite plan branch count does not match the prepared transition"
50
+ end
51
+
52
+ def captured_agent(authorization, transition, agent, role)
53
+ authorization.fetch_agent!(
54
+ agent,
55
+ workflow_class: self.class,
56
+ transition_name: transition.name,
57
+ role:
58
+ )
59
+ end
60
+
61
+ def validate_composite_branches!(authorization, plan, transition)
62
+ specs = composite_branch_specs(authorization, transition, branch_count: plan.branches.length)
63
+ specs.each_with_index do |spec, ordinal|
64
+ expected = comparable_branch(Branch.build(ordinal:, **spec))
65
+ actual = comparable_branch(plan.branches.fetch(ordinal))
66
+ next if actual == expected
67
+
68
+ raise WorkflowError, "composite plan branches do not match the prepared transition"
69
+ end
70
+ end
71
+
72
+ def validate_composite_selected_branch!(authorization, execution, transition)
73
+ branch = execution.branch
74
+ spec = selected_branch_spec(authorization, execution, transition)
75
+ candidate = Branch.build(ordinal: branch.ordinal, **spec)
76
+ raise WorkflowError, "composite branch does not match the prepared transition" unless
77
+ comparable_branch(candidate) == comparable_branch(branch)
78
+ end
79
+
80
+ def selected_branch_spec(authorization, execution, transition)
81
+ return selected_parallel_branch_spec(authorization, execution, transition) if transition.parallel?
82
+
83
+ selected_fanout_branch_spec(authorization, execution, transition)
84
+ end
85
+
86
+ def selected_parallel_branch_spec(authorization, execution, transition)
87
+ branch = execution.branch
88
+ count = execution.branch_count
89
+ validate_planned_parallel_count!(transition, count)
90
+ agent = transition.agent_name
91
+ agent_class = captured_agent(authorization, transition, agent, :agent)
92
+ allocator = composite_budget_allocator(count)
93
+ parallel_branch_spec(agent, agent_class, allocator, branch.ordinal).merge(key: branch.ordinal.to_s)
94
+ end
95
+
96
+ def comparable_branch(branch)
97
+ [branch.ordinal, branch.key, branch.agent, branch.binding_identity, branch.budget]
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../prepared_step"
5
+ require_relative "../prepared_step_dispatch"
6
+ require_relative "branch"
7
+ require_relative "enums"
8
+ require_relative "payload"
9
+ require_relative "payload_digest"
10
+ require_relative "plan"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class BranchExecution < Payload
16
+ VERSION = 1
17
+ ENUM_ATTRIBUTES = %i[kind resume_policy failure_policy reduction_policy retry_policy].freeze
18
+ private_constant :ENUM_ATTRIBUTES
19
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
20
+ private_constant :OwnedString
21
+
22
+ attribute :version, Types::Integer.enum(VERSION)
23
+ attribute :execution_semantics_version, OwnedString.constrained(min_size: 1, max_size: 32)
24
+ attribute :dispatch, Types.Instance(PreparedStepDispatch)
25
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
26
+ attribute :kind, Types::Symbol.enum(:parallel, :fanout)
27
+ attribute :transition, OwnedString.constrained(min_size: 1, max_size: 256)
28
+ attribute :from, OwnedString.constrained(min_size: 1, max_size: 256)
29
+ attribute :execution_namespace, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
30
+ attribute :branch_count, Types::Integer.constrained(gteq: 1, lteq: Plan::MAX_BRANCHES)
31
+ attribute :input_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
32
+ attribute :budget_state_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
33
+ attribute :resume_policy, Types::Symbol.enum(Plan::RESUME_POLICY)
34
+ attribute :failure_policy, Types::Symbol.enum(Plan::FAILURE_POLICY)
35
+ attribute :reduction_policy, Types::Symbol.enum(Plan::REDUCTION_POLICY)
36
+ attribute :retry_policy, Types::Symbol.enum(Plan::RETRY_POLICY)
37
+ attribute :branch, Types.Instance(Branch)
38
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
39
+
40
+ class << self
41
+ def build(plan:, branch:)
42
+ validate_plan_branch!(plan, branch)
43
+ values = execution_attributes(plan, branch)
44
+ new(values.merge(digest: PayloadDigest.call(serializable(values))))
45
+ end
46
+
47
+ def normalize_attributes(attributes)
48
+ normalized = super
49
+ normalize_dispatch!(normalized)
50
+ normalize_branch!(normalized)
51
+ ENUM_ATTRIBUTES.each { |key| normalized[key] = Enums.normalize(key, normalized[key]) }
52
+ normalized
53
+ end
54
+
55
+ def serializable(values)
56
+ values.merge(dispatch: values.fetch(:dispatch).to_h, branch: values.fetch(:branch).to_h)
57
+ end
58
+
59
+ private
60
+
61
+ def execution_attributes(plan, branch)
62
+ {
63
+ version: VERSION,
64
+ execution_semantics_version: plan.execution_semantics_version,
65
+ dispatch: plan.dispatch,
66
+ plan_digest: plan.plan_digest,
67
+ kind: plan.kind,
68
+ transition: plan.transition,
69
+ from: plan.from,
70
+ execution_namespace: plan.execution_namespace,
71
+ branch_count: plan.branches.length,
72
+ input_digest: plan.input_digest,
73
+ budget_state_digest: plan.budget_state_digest,
74
+ resume_policy: plan.resume_policy,
75
+ failure_policy: plan.failure_policy,
76
+ reduction_policy: plan.reduction_policy,
77
+ retry_policy: plan.retry_policy,
78
+ branch:
79
+ }
80
+ end
81
+
82
+ def validate_plan_branch!(plan, branch)
83
+ raise ArgumentError, "plan must be a Smith composite plan" unless plan.is_a?(Plan)
84
+ raise ArgumentError, "branch must be a Smith composite branch" unless branch.is_a?(Branch)
85
+
86
+ expected = plan.branches.fetch(branch.ordinal) do
87
+ raise ArgumentError, "composite branch ordinal is invalid"
88
+ end
89
+ raise ArgumentError, "composite branch does not belong to plan" unless expected.to_h == branch.to_h
90
+ end
91
+
92
+ def normalize_dispatch!(attributes)
93
+ value = attributes[:dispatch]
94
+ attributes[:dispatch] = PreparedStepDispatch.deserialize(value) unless value.is_a?(PreparedStepDispatch)
95
+ end
96
+
97
+ def normalize_branch!(attributes)
98
+ value = attributes[:branch]
99
+ attributes[:branch] = Branch.deserialize(value) unless value.is_a?(Branch)
100
+ end
101
+ end
102
+
103
+ def initialize(attributes)
104
+ owned = self.class.normalize_attributes(attributes)
105
+ super(owned)
106
+ validate_contract!
107
+ end
108
+
109
+ private
110
+
111
+ def validate_contract!
112
+ Plan.validate_branch_count!(branch_count)
113
+ raise ArgumentError, "composite branch ordinal exceeds branch count" if branch.ordinal >= branch_count
114
+ unless execution_semantics_version == Smith::EXECUTION_SEMANTICS_VERSION
115
+ raise ArgumentError, "composite branch execution semantics do not match"
116
+ end
117
+
118
+ expected = PayloadDigest.call(self.class.serializable(to_h.except(:digest)))
119
+ raise ArgumentError, "composite branch execution digest does not match" unless digest == expected
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end