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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/README.md +83 -0
- data/lib/smith/agent.rb +13 -0
- data/lib/smith/errors.rb +4 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/composite/branch.rb +93 -0
- data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
- data/lib/smith/workflow/composite/branch_contract.rb +102 -0
- data/lib/smith/workflow/composite/branch_execution.rb +124 -0
- data/lib/smith/workflow/composite/branch_failure.rb +91 -0
- data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
- data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
- data/lib/smith/workflow/composite/budget_math.rb +51 -0
- data/lib/smith/workflow/composite/contract.rb +100 -0
- data/lib/smith/workflow/composite/effects.rb +140 -0
- data/lib/smith/workflow/composite/effects_application.rb +36 -0
- data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
- data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
- data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
- data/lib/smith/workflow/composite/enums.rb +27 -0
- data/lib/smith/workflow/composite/error.rb +54 -0
- data/lib/smith/workflow/composite/error_evidence.rb +61 -0
- data/lib/smith/workflow/composite/execution_contract.rb +73 -0
- data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
- data/lib/smith/workflow/composite/input.rb +53 -0
- data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
- data/lib/smith/workflow/composite/outcome_set.rb +23 -0
- data/lib/smith/workflow/composite/payload.rb +128 -0
- data/lib/smith/workflow/composite/payload_digest.rb +28 -0
- data/lib/smith/workflow/composite/plan.rb +130 -0
- data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
- data/lib/smith/workflow/composite/planner.rb +53 -0
- data/lib/smith/workflow/composite/preparation.rb +27 -0
- data/lib/smith/workflow/composite/reducer.rb +133 -0
- data/lib/smith/workflow/composite/reduction.rb +31 -0
- data/lib/smith/workflow/composite/value_budget.rb +90 -0
- data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
- data/lib/smith/workflow/deadline_enforcement.rb +22 -5
- data/lib/smith/workflow/execution.rb +16 -16
- data/lib/smith/workflow/fanout_execution.rb +19 -18
- data/lib/smith/workflow/message_value_normalizer.rb +11 -10
- data/lib/smith/workflow/parallel_execution.rb +41 -13
- data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +14 -29
- data/lib/smith/workflow/prepared_step_execution_scope.rb +81 -15
- data/lib/smith/workflow/process_local.rb +33 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
- data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +7 -53
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +41 -21
- data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +17 -3
- data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +17 -5
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +21 -5
- data/lib/smith/workflow/split_step_persistence.rb +14 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
- data/lib/smith/workflow/transition.rb +16 -1
- data/lib/smith/workflow.rb +21 -1
- data/lib/smith.rb +3 -0
- metadata +46 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 986d706ecdf0a51035e20dde373106a6c6df18bc2150cae87e117993bd3fcd1e
|
|
4
|
+
data.tar.gz: e67ca0f8e6b4d6d35c5e1683876a9e191d0da92e29bee4c4379ca8b60b77c50e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|