smith-agents 0.5.0 → 0.6.0

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +63 -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/subclass_boundary.rb +21 -5
  63. data/lib/smith/workflow/split_step_persistence.rb +14 -0
  64. data/lib/smith/workflow/step_context.rb +46 -0
  65. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  66. data/lib/smith/workflow/transition.rb +16 -1
  67. data/lib/smith/workflow.rb +21 -1
  68. data/lib/smith.rb +3 -0
  69. 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: 986d706ecdf0a51035e20dde373106a6c6df18bc2150cae87e117993bd3fcd1e
4
+ data.tar.gz: e67ca0f8e6b4d6d35c5e1683876a9e191d0da92e29bee4c4379ca8b60b77c50e
5
5
  SHA512:
6
- metadata.gz: f75713ef01aa104f3add7f6804dc8f77061ad12ea787598a16dce09bb0b08c23e256f35b346d4f3e53c99b86a0bdb5603ca6f7236dc66a1c60130a16ab9180a5
7
- data.tar.gz: 28953ec750b620efff05055af313031eb24e97f6591dea4ea04b029d8c71d0a72de37d2aecb6ec78c532f73d3301d854b6f853bd26515abd98f93a5fe4668edb
6
+ metadata.gz: fa10aed2a7fc3874e8629af28beb07c230d5632b5a653630e70374931c8067775b8f17bee8c70a5c7e96c723e70dbad10c693491c37f8f4bb4a3c7f29b5aed1e
7
+ data.tar.gz: 13560c156073e79e1950d46e460a7f8a1a964a3af6d9b63b68394a0835b6b117f1fb60636c8cecb62d4fb6380e9064a535607cd2e57155e92f64915d2ab76228
data/CHANGELOG.md CHANGED
@@ -6,6 +6,69 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.6.0] - 2026-07-20
10
+
11
+ ### Added
12
+
13
+ - Add a host-neutral durable composite lifecycle for same-agent parallel and
14
+ heterogeneous fan-out transitions. Smith emits immutable bounded plans and
15
+ inputs, executes one authorized branch through captured agent bindings, and
16
+ deterministically reduces a complete ordered outcome set through the normal
17
+ transition completion and failure paths.
18
+ - Add exact transport contracts for ordered branch descriptors, redacted branch
19
+ failures, usage/tool/budget effects, and host-committed primary failure
20
+ selection. Composite branch retries remain explicitly unsupported; hosts own
21
+ scheduling, persistence, claims, fences, and incomplete-branch resumption.
22
+ - Add compact selected-branch execution envelopes and stable host-declared agent
23
+ execution identities so workers do not receive the full plan and registry
24
+ replacement after planning fails closed.
25
+ - Add scoped prepare, selected-branch execution, and reduction entry points so
26
+ process-local authority never crosses the supported public composite API.
27
+
28
+ ### Changed
29
+
30
+ - Bump `Smith::EXECUTION_SEMANTICS_VERSION` to `2` so hosts invalidate generated
31
+ executable definitions when adopting the composite lifecycle.
32
+ - Canonicalize aggregate branch output to the same JSON-compatible shape before
33
+ and after persistence.
34
+
35
+ ### Fixed
36
+
37
+ - Preserve complete workflow, agent, and branch execution context across
38
+ nested parallel and heterogeneous fan-out work without leaking thread- or
39
+ Fiber-local Smith state.
40
+ - Keep ordinary subclass setup and teardown extension points intact while
41
+ prepared execution seals framework-owned branch dispatch against subclass
42
+ replacement.
43
+ - Restrict prepared execution authority to the exact coordinator or registered
44
+ branch Fiber, reject copied or serialized process-local authority, and make
45
+ activation, cleanup, and context restoration safe against asynchronous Ruby
46
+ thread interruption.
47
+ - Keep composite planning and full-run validation linear in branch count, make
48
+ individual branch binding capture and validation constant-time in branch
49
+ count, reject the hard transport ceiling before descriptor allocation, and
50
+ incrementally enforce cumulative effect/output envelopes before consuming
51
+ execution authority.
52
+ - Isolate decimal aggregation from host `BigDecimal` precision, reject aggregate
53
+ usage overflow, and preserve composite branch identity and retry
54
+ classification across persistence.
55
+ - Bind one artifact execution namespace into every branch and the reducer,
56
+ reject aggregate budget overrun and known usage replay before consuming
57
+ authority, and validate cumulative persisted token/cost accounting.
58
+ - Enforce complete canonical payload fields and encoded JSON byte limits before
59
+ returning transport values.
60
+ - Allocate disjoint branch budget envelopes without exceeding the parent,
61
+ reconcile usage against branch-local token/cost consumption, bind branch
62
+ authority to one exact execution envelope, and prevent subclass collisions
63
+ from bypassing durable dispatch verification.
64
+ - Reject forged retryability, untrusted error metadata, and unknown transport
65
+ keys without symbol interning; validate scalar and replay failures before
66
+ snapshotting tool effects.
67
+ - Reject unknown transport enum values without symbol interning, reject corrupt
68
+ persisted branch-failure evidence instead of inventing defaults, enforce
69
+ cumulative encoded-effect bounds while streaming outcomes, and close active
70
+ branch authority before asynchronous interruption can escape a scoped call.
71
+
9
72
  ## [0.5.0] - 2026-07-19
10
73
 
11
74
  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.0"
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
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../errors"
4
+ require_relative "error"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module Composite
9
+ class BranchFailure < WorkflowError
10
+ DETAIL_KEYS = {
11
+ "branch_key" => :branch_key,
12
+ "error_class" => :error_class,
13
+ "error_family" => :error_family,
14
+ "retryable" => :retryable,
15
+ "kind" => :kind
16
+ }.freeze
17
+ DETAIL_NAMES = DETAIL_KEYS.values.freeze
18
+ private_constant :DETAIL_KEYS, :DETAIL_NAMES
19
+
20
+ attr_reader :branch_key, :error_class, :error_family, :retryable, :kind, :details
21
+
22
+ def self.from_details(details)
23
+ values = normalize_details(details)
24
+ error = Error.new(
25
+ class_name: values.fetch(:error_class),
26
+ family: values.fetch(:error_family),
27
+ retryable: values.fetch(:retryable),
28
+ kind: values.fetch(:kind)
29
+ )
30
+ new(branch_key: values.fetch(:branch_key), error:)
31
+ end
32
+
33
+ def self.normalize_details(details)
34
+ raise ArgumentError, "composite branch failure details must be a Hash" unless details.is_a?(Hash)
35
+
36
+ normalized = {}
37
+ Hash.instance_method(:each_pair).bind_call(details) do |key, value|
38
+ name = normalize_detail_key(key)
39
+ if normalized.key?(name)
40
+ raise ArgumentError, "composite branch failure details contain a duplicate attribute"
41
+ end
42
+
43
+ normalized[name] = value
44
+ end
45
+ missing = DETAIL_NAMES - normalized.keys
46
+ raise ArgumentError, "composite branch failure details are missing required attributes" if missing.any?
47
+
48
+ normalized
49
+ end
50
+
51
+ def self.normalize_detail_key(key)
52
+ name = key.is_a?(Symbol) ? key : DETAIL_KEYS.fetch(key, key)
53
+ return name if DETAIL_NAMES.include?(name)
54
+
55
+ raise ArgumentError, "composite branch failure details contain an unknown attribute"
56
+ end
57
+ private_class_method :normalize_details
58
+ private_class_method :normalize_detail_key
59
+
60
+ def initialize(branch_key:, error:)
61
+ validate_arguments!(branch_key, error)
62
+ @branch_key = branch_key.dup.freeze
63
+ @error_class = error.class_name.dup.freeze
64
+ @error_family = error.family.dup.freeze
65
+ @retryable = error.retryable
66
+ @kind = error.kind&.dup&.freeze
67
+ @details = failure_details
68
+ super("composite branch #{branch_key.inspect} failed")
69
+ end
70
+
71
+ private
72
+
73
+ def validate_arguments!(branch_key, error)
74
+ valid_key = branch_key.is_a?(String) && branch_key.length.between?(1, 256)
75
+ raise ArgumentError, "composite branch failure key must be a bounded non-empty String" unless valid_key
76
+ raise ArgumentError, "composite branch failure requires typed error evidence" unless error.is_a?(Error)
77
+ end
78
+
79
+ def failure_details
80
+ {
81
+ branch_key: @branch_key,
82
+ error_class: @error_class,
83
+ error_family: @error_family,
84
+ retryable: @retryable,
85
+ kind: @kind
86
+ }.freeze
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end