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
@@ -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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../message_value_normalizer"
5
+ require_relative "../prepared_step"
6
+ require_relative "effects"
7
+ require_relative "enums"
8
+ require_relative "error"
9
+ require_relative "payload"
10
+ require_relative "payload_digest"
11
+
12
+ module Smith
13
+ class Workflow
14
+ module Composite
15
+ class BranchOutcome < Payload
16
+ OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
17
+ private_constant :OwnedString
18
+
19
+ attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
20
+ attribute :branch_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
21
+ attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
22
+ attribute :branch_key, OwnedString.constrained(min_size: 1, max_size: 256)
23
+ attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
24
+ attribute :status, Types::Symbol.enum(:succeeded, :failed)
25
+ attribute? :output, Types::Any.optional
26
+ attribute? :error, Types.Instance(Error).optional
27
+ attribute :effects, Types.Instance(Effects)
28
+ attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
29
+
30
+ class << self
31
+ def succeeded(plan_digest:, branch:, output:, effects:)
32
+ build(plan_digest:, branch:, effects:, status: :succeeded, output:, error: nil)
33
+ end
34
+
35
+ def failed(plan_digest:, branch:, error:, effects:)
36
+ build(plan_digest:, branch:, effects:, status: :failed, output: nil, error:)
37
+ end
38
+
39
+ def normalize_attributes(attributes)
40
+ normalized = super
41
+ normalize_status!(normalized)
42
+ normalize_error!(normalized)
43
+ normalize_effects!(normalized)
44
+ normalized
45
+ end
46
+
47
+ private
48
+
49
+ def build(plan_digest:, branch:, effects:, **result)
50
+ values = {
51
+ plan_digest:,
52
+ branch_digest: branch.digest,
53
+ ordinal: branch.ordinal,
54
+ branch_key: branch.key,
55
+ agent: branch.agent,
56
+ status: result.fetch(:status),
57
+ output: normalize_output(result.fetch(:output)),
58
+ error: result.fetch(:error),
59
+ effects:
60
+ }
61
+ new(values.merge(digest: PayloadDigest.call(serializable(values))))
62
+ end
63
+
64
+ def normalize_status!(attributes)
65
+ attributes[:status] = Enums.normalize(:status, attributes[:status])
66
+ end
67
+
68
+ def normalize_error!(attributes)
69
+ error = attributes[:error]
70
+ attributes[:error] = Error.deserialize(error) if error && !error.is_a?(Error)
71
+ end
72
+
73
+ def normalize_effects!(attributes)
74
+ effects = attributes[:effects]
75
+ attributes[:effects] = Effects.deserialize(effects) unless effects.is_a?(Effects)
76
+ end
77
+
78
+ def normalize_output(value)
79
+ MessageValueNormalizer.new(value, label: "composite branch output").call
80
+ end
81
+
82
+ def serializable(values)
83
+ values.merge(error: values[:error]&.to_h, effects: values.fetch(:effects).to_h)
84
+ end
85
+ end
86
+
87
+ def initialize(attributes)
88
+ owned = self.class.normalize_attributes(attributes)
89
+ owned[:output] = self.class.send(:normalize_output, owned[:output])
90
+ super(owned)
91
+ validate_shape!
92
+ expected = PayloadDigest.call(self.class.send(:serializable, to_h.except(:digest)))
93
+ raise ArgumentError, "composite branch outcome digest does not match" unless digest == expected
94
+ end
95
+
96
+ def succeeded? = status == :succeeded
97
+ def failed? = status == :failed
98
+
99
+ private
100
+
101
+ def validate_shape!
102
+ valid = succeeded? ? error.nil? : error && output.nil?
103
+ raise ArgumentError, "composite branch outcome fields do not match status" unless valid
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "dry-initializer"
5
+
6
+ require_relative "../../budget/decimal_context"
7
+ require_relative "../../budget/ledger"
8
+ require_relative "plan"
9
+
10
+ module Smith
11
+ class Workflow
12
+ module Composite
13
+ class BudgetAllocator
14
+ extend Dry::Initializer
15
+
16
+ TOKEN_DIMENSIONS = %i[total_tokens token_limit].freeze
17
+ SUPPORTED_DIMENSIONS = (TOKEN_DIMENSIONS + %i[total_cost]).freeze
18
+
19
+ option :ledger
20
+ option :branch_count
21
+
22
+ def initialize(...)
23
+ super
24
+ unless ledger.is_a?(Budget::Ledger)
25
+ raise ArgumentError, "composite budget allocation requires a budget ledger"
26
+ end
27
+
28
+ Plan.validate_branch_count!(branch_count)
29
+ @remaining = ledger.limits.to_h { |dimension, _| [dimension, ledger.remaining(dimension)] }.freeze
30
+ end
31
+
32
+ def call(ordinal:, caps: {})
33
+ validate_ordinal!(ordinal)
34
+
35
+ @remaining.to_h do |dimension, amount|
36
+ [dimension, capped_share(dimension, amount, ordinal, caps[dimension])]
37
+ end.freeze
38
+ end
39
+
40
+ private
41
+
42
+ def validate_ordinal!(ordinal)
43
+ return if ordinal.is_a?(Integer) && ordinal.between?(0, branch_count - 1)
44
+
45
+ raise ArgumentError, "composite branch ordinal is outside the allocation"
46
+ end
47
+
48
+ def capped_share(dimension, amount, ordinal, cap)
49
+ unless cap.nil? || (cap.is_a?(Numeric) && cap.finite? && cap >= 0)
50
+ raise ArgumentError, "composite branch budget cap must be a finite non-negative number"
51
+ end
52
+
53
+ allocation = share(dimension, amount, ordinal)
54
+ cap.nil? ? allocation : [allocation, cap].min
55
+ end
56
+
57
+ def share(dimension, amount, ordinal)
58
+ return 0 unless SUPPORTED_DIMENSIONS.include?(dimension)
59
+ return integer_share(amount, ordinal) if TOKEN_DIMENSIONS.include?(dimension) && amount.is_a?(Integer)
60
+
61
+ decimal_share(amount)
62
+ end
63
+
64
+ def integer_share(amount, ordinal)
65
+ quotient, remainder = amount.divmod(branch_count)
66
+ quotient + (ordinal < remainder ? 1 : 0)
67
+ end
68
+
69
+ def decimal_share(amount)
70
+ exact = Budget::DecimalContext.call { BigDecimal(amount.to_s) / branch_count }
71
+ candidate = exact.to_f
72
+ candidate = candidate.prev_float if BigDecimal(candidate.to_s) > exact
73
+ candidate
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "dry-initializer"
5
+
6
+ require_relative "../../budget/decimal_context"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class BudgetMath
12
+ extend Dry::Initializer
13
+
14
+ HASH_EACH_PAIR = Hash.instance_method(:each_pair)
15
+ private_constant :HASH_EACH_PAIR
16
+
17
+ param :consumptions
18
+
19
+ def self.sum(consumptions) = new(consumptions).sum
20
+
21
+ def sum
22
+ Budget::DecimalContext.call do
23
+ totals = {}
24
+ integer_dimensions = {}
25
+ consumptions.each { accumulate!(_1, totals, integer_dimensions) }
26
+ externalize(totals, integer_dimensions)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def accumulate!(value, totals, integer_dimensions)
33
+ raise ArgumentError, "composite budget consumption must be a Hash" unless value.is_a?(Hash)
34
+
35
+ HASH_EACH_PAIR.bind_call(value) do |dimension, amount|
36
+ key = dimension.to_s
37
+ totals[key] = totals.fetch(key, BigDecimal("0")) + BigDecimal(amount.to_s)
38
+ integer_dimensions[key] = integer_dimensions.fetch(key, true) && amount.is_a?(Integer)
39
+ end
40
+ end
41
+
42
+ def externalize(totals, integer_dimensions)
43
+ totals.to_h do |dimension, total|
44
+ value = integer_dimensions.fetch(dimension) ? total.to_i : total.to_f
45
+ [dimension, value]
46
+ end.freeze
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "branch_contract"
4
+ require_relative "branch_execution"
5
+ require_relative "execution_contract"
6
+ require_relative "input"
7
+ require_relative "plan"
8
+ require_relative "../composite_branch_execution_authorization"
9
+
10
+ module Smith
11
+ class Workflow
12
+ module Composite
13
+ module Contract
14
+ include BranchContract
15
+ include ExecutionContract
16
+
17
+ private
18
+
19
+ def validate_composite_authorization!(authorization)
20
+ authorization = validate_split_step_execution_authorization!(authorization)
21
+ @split_step_mutex.synchronize do
22
+ unless active_split_step_execution_authorization?(authorization)
23
+ raise WorkflowError, "the prepared-step execution authorization is no longer active"
24
+ end
25
+
26
+ ensure_split_step_definition_current!
27
+ ensure_prepared_split_step_transition_matches!
28
+ end
29
+ validate_composite_transition!(@split_step_transition)
30
+ unless authorization.dispatch_claim
31
+ raise WorkflowError, "durable composite execution requires a prepared-step dispatch"
32
+ end
33
+
34
+ authorization
35
+ end
36
+
37
+ def validate_composite_transition!(transition)
38
+ unless transition&.fanout? || transition&.parallel?
39
+ raise WorkflowError, "prepared transition is not a supported composite"
40
+ end
41
+ raise WorkflowError, "durable composite retries are not supported" if transition.retry_config
42
+
43
+ transition
44
+ end
45
+
46
+ def validate_composite_plan!(authorization, plan, input)
47
+ validate_composite_payload_types!(plan, input)
48
+ validate_composite_dispatch!(authorization, plan)
49
+ validate_composite_input!(plan, input)
50
+ validate_composite_budget_state!(plan)
51
+ validate_composite_execution_namespace!(plan)
52
+ transition = @split_step_transition
53
+ validate_composite_transition_identity!(plan, transition)
54
+ plan
55
+ end
56
+
57
+ def validate_composite_branch_execution!(authorization, execution, input)
58
+ validate_composite_branch_payload_types!(execution, input)
59
+ validate_composite_dispatch_value!(authorization, execution.dispatch)
60
+ validate_composite_input_digest!(execution.input_digest, input)
61
+ validate_composite_budget_state_digest!(execution.budget_state_digest)
62
+ validate_composite_execution_namespace_value!(execution.execution_namespace)
63
+ validate_composite_transition_values!(execution, @split_step_transition)
64
+ validate_composite_branch_count!(execution.branch_count)
65
+ validate_composite_branch_authorization!(authorization, execution, input)
66
+ validate_composite_selected_branch!(authorization, execution, @split_step_transition)
67
+ execution
68
+ end
69
+
70
+ def validate_composite_reduction_plan!(authorization, plan, input)
71
+ validate_composite_plan!(authorization, plan, input)
72
+ validate_composite_branches!(authorization, plan, @split_step_transition)
73
+ plan
74
+ end
75
+
76
+ def validate_composite_payload_types!(plan, input)
77
+ raise ArgumentError, "plan must be a Smith composite plan" unless plan.is_a?(Plan)
78
+ raise ArgumentError, "input must be a Smith composite input" unless input.is_a?(Input)
79
+ end
80
+
81
+ def validate_composite_branch_payload_types!(execution, input)
82
+ unless execution.is_a?(BranchExecution)
83
+ raise ArgumentError, "execution must be a Smith composite branch execution"
84
+ end
85
+ raise ArgumentError, "input must be a Smith composite input" unless input.is_a?(Input)
86
+ end
87
+
88
+ def validate_composite_branch_authorization!(authorization, execution, input)
89
+ unless authorization.instance_of?(CompositeBranchExecutionAuthorization)
90
+ raise ArgumentError, "authorization must be a Smith composite branch authorization"
91
+ end
92
+
93
+ CompositeBranchExecutionAuthorization
94
+ .instance_method(:verify_composite_branch!)
95
+ .bind_call(authorization, execution, input)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../../budget/decimal_context"
5
+ require_relative "../message_value_normalizer"
6
+ require_relative "../prepared_step"
7
+ require_relative "payload"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module Composite
12
+ class Effects < Payload
13
+ attr_reader :total_tokens, :total_cost
14
+
15
+ USAGE_ATTRIBUTES = %w[
16
+ usage_id agent_name model input_tokens output_tokens cost attempt_kind recorded_at
17
+ ].freeze
18
+ TOOL_ATTRIBUTES = %w[tool captured].freeze
19
+ private_constant :USAGE_ATTRIBUTES, :TOOL_ATTRIBUTES
20
+
21
+ attribute :usage_entries, Types::Array
22
+ attribute :tool_results, Types::Array
23
+ attribute :budget_consumed, Types::Hash
24
+
25
+ def initialize(attributes)
26
+ owned = self.class.normalize_attributes(attributes)
27
+ normalized = MessageValueNormalizer.new(owned, label: "composite effects").call
28
+ usage_entries = normalized.fetch("usage_entries")
29
+ tool_results = normalized.fetch("tool_results")
30
+ budget_consumed = normalized.fetch("budget_consumed")
31
+ @total_tokens, @total_cost = validate_usage_entries!(usage_entries)
32
+ validate_tool_results!(tool_results)
33
+ validate_budget!(budget_consumed)
34
+ super(
35
+ usage_entries:,
36
+ tool_results:,
37
+ budget_consumed:
38
+ )
39
+ end
40
+
41
+ private
42
+
43
+ def validate_usage_entries!(entries)
44
+ raise ArgumentError, "composite usage entries must be an Array" unless entries.is_a?(Array)
45
+
46
+ entries.each do |entry|
47
+ validate_exact_keys!(entry, USAGE_ATTRIBUTES, "composite usage entry")
48
+ validate_usage_identity!(entry)
49
+ validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
50
+ validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
51
+ validate_cost!(entry.fetch("cost"))
52
+ end
53
+ usage_totals(entries)
54
+ end
55
+
56
+ def usage_totals(entries)
57
+ tokens = entries.sum { _1.fetch("input_tokens") + _1.fetch("output_tokens") }
58
+ if tokens > PreparedStep::MAX_COUNTER_VALUE
59
+ raise ArgumentError, "composite usage token total exceeds the signed 64-bit limit"
60
+ end
61
+
62
+ cost = Budget::DecimalContext.call do
63
+ entries.sum(BigDecimal("0")) { BigDecimal((_1.fetch("cost") || 0).to_s) }
64
+ end.to_f
65
+ raise ArgumentError, "composite usage cost total must be finite" unless cost.finite?
66
+
67
+ [tokens, cost]
68
+ end
69
+
70
+ def validate_usage_identity!(entry)
71
+ validate_usage_id!(entry.fetch("usage_id"))
72
+ validate_agent_name!(entry.fetch("agent_name"))
73
+ %w[model attempt_kind recorded_at].each do |key|
74
+ validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
75
+ end
76
+ end
77
+
78
+ def validate_usage_id!(usage_id)
79
+ return if usage_id.is_a?(String) && PreparedStep::UUID_PATTERN.match?(usage_id)
80
+
81
+ raise ArgumentError, "composite usage entry usage_id must be a UUID"
82
+ end
83
+
84
+ def validate_agent_name!(agent_name)
85
+ return if agent_name.nil?
86
+
87
+ validate_nonempty_string!(agent_name, "composite usage entry agent_name")
88
+ end
89
+
90
+ def validate_nonempty_string!(value, label)
91
+ return if value.is_a?(String) && !value.empty?
92
+
93
+ raise ArgumentError, "#{label} must be a non-empty String"
94
+ end
95
+
96
+ def validate_usage_amount!(amount, name)
97
+ return if amount.is_a?(Integer) && amount >= 0
98
+
99
+ raise ArgumentError, "composite usage entry #{name} must be a non-negative Integer"
100
+ end
101
+
102
+ def validate_cost!(cost)
103
+ return if cost.nil?
104
+ return if cost.is_a?(Numeric) && cost.finite? && cost >= 0
105
+
106
+ raise ArgumentError, "composite usage entry cost must be a finite non-negative number or nil"
107
+ end
108
+
109
+ def validate_tool_results!(entries)
110
+ raise ArgumentError, "composite tool results must be an Array" unless entries.is_a?(Array)
111
+
112
+ entries.each do |entry|
113
+ validate_exact_keys!(entry, TOOL_ATTRIBUTES, "composite tool result")
114
+ tool = entry.fetch("tool")
115
+ unless tool.is_a?(String) && tool.length.between?(1, 256)
116
+ raise ArgumentError, "composite tool result tool must be a bounded non-empty String"
117
+ end
118
+ end
119
+ end
120
+
121
+ def validate_budget!(budget)
122
+ raise ArgumentError, "composite budget consumption must be a Hash" unless budget.is_a?(Hash)
123
+
124
+ budget.each do |dimension, amount|
125
+ unless !dimension.empty? && amount.is_a?(Numeric) && amount.finite? && amount >= 0
126
+ raise ArgumentError, "composite budget consumption is invalid"
127
+ end
128
+ end
129
+ end
130
+
131
+ def validate_exact_keys!(value, expected, label)
132
+ raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
133
+ return if value.keys.sort == expected.sort
134
+
135
+ raise ArgumentError, "#{label} attributes are invalid"
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../../budget/ledger"
5
+ require_relative "../usage_entry"
6
+ require_relative "effects_baseline"
7
+
8
+ module Smith
9
+ class Workflow
10
+ module Composite
11
+ class EffectsApplication < Dry::Struct
12
+ NumericType = Types::Integer | Types::Float
13
+ private_constant :NumericType
14
+
15
+ attribute :usage_entries, Types::Array.of(Types.Instance(UsageEntry))
16
+ attribute :tool_results, Types::Array.of(Types::Hash)
17
+ attribute :total_tokens, Types::Integer
18
+ attribute :total_cost, NumericType
19
+ attribute :ledger, Types.Instance(Budget::Ledger).optional
20
+ attribute :baseline, Types.Instance(EffectsBaseline)
21
+
22
+ def initialize(attributes)
23
+ super
24
+ unless total_tokens >= 0 && total_cost.finite? && total_cost >= 0
25
+ raise ArgumentError, "composite effects application totals are invalid"
26
+ end
27
+
28
+ usage_entries.freeze
29
+ tool_results.freeze
30
+ self.attributes.freeze
31
+ freeze
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../types"
4
+ require_relative "../../budget/ledger"
5
+ require_relative "../usage_entry"
6
+
7
+ module Smith
8
+ class Workflow
9
+ module Composite
10
+ class EffectsBaseline < Dry::Struct
11
+ NumericType = Types::Integer | Types::Float
12
+ private_constant :NumericType
13
+
14
+ attribute :usage_entries, Types::Array.of(Types.Instance(UsageEntry))
15
+ attribute :tool_results, Types::Array.of(Types::Hash)
16
+ attribute :total_tokens, Types::Integer
17
+ attribute :total_cost, NumericType
18
+ attribute :ledger, Types.Instance(Budget::Ledger).optional
19
+ attribute :budget_consumed, Types::Hash
20
+
21
+ def initialize(attributes)
22
+ super
23
+ unless total_tokens >= 0 && total_cost.finite? && total_cost >= 0
24
+ raise ArgumentError, "composite effects baseline totals are invalid"
25
+ end
26
+
27
+ usage_entries.freeze
28
+ tool_results.freeze
29
+ budget_consumed.freeze
30
+ self.attributes.freeze
31
+ freeze
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end