smith-agents 0.8.0 → 0.11.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 +433 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +26 -6
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/completion_usage_recording.rb +21 -3
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/invocation_preparation.rb +24 -0
- data/lib/smith/agent/lifecycle.rb +4 -2
- data/lib/smith/agent/provider_attempt.rb +17 -4
- data/lib/smith/agent/provider_call_timing.rb +129 -0
- data/lib/smith/agent/provider_completion.rb +26 -8
- data/lib/smith/agent/provider_failure_handling.rb +2 -2
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_entry_recording.rb +27 -4
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +26 -20
- data/lib/smith/agent.rb +9 -1
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/budget/amount_representation.rb +20 -0
- data/lib/smith/budget/ledger.rb +6 -2
- data/lib/smith/context/state_injection.rb +12 -2
- data/lib/smith/diagnostic_text.rb +10 -1
- data/lib/smith/doctor/checks/durability.rb +16 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
- data/lib/smith/errors.rb +55 -4
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +8 -3
- data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
- data/lib/smith/persistence_adapters.rb +10 -0
- data/lib/smith/provider_permanent_failure.rb +44 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool.rb +10 -1
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/composite/effects.rb +64 -33
- data/lib/smith/workflow/deterministic_execution.rb +3 -2
- data/lib/smith/workflow/deterministic_step.rb +15 -2
- data/lib/smith/workflow/dsl.rb +40 -6
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +111 -11
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +10 -0
- data/lib/smith/workflow/failure_reconstructor.rb +18 -2
- data/lib/smith/workflow/failure_record.rb +6 -11
- data/lib/smith/workflow/failure_record_validator.rb +23 -3
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guardrail_integration.rb +27 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -6
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +13 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +1 -0
- data/lib/smith/workflow/usage_entry.rb +32 -4
- data/lib/smith/workflow.rb +30 -3
- data/lib/smith.rb +9 -4
- metadata +5 -1
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "diagnostic_text"
|
|
3
4
|
require_relative "error"
|
|
4
5
|
|
|
5
6
|
module Smith
|
|
6
7
|
class ProviderPermanentFailure < Error
|
|
8
|
+
DETAIL_NAMES = %i[provider model_id source_error_class].freeze
|
|
9
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
10
|
+
MAX_DETAIL_BYTES = 512
|
|
11
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
|
|
12
|
+
|
|
7
13
|
attr_reader :provider, :model_id, :source_error_class
|
|
8
14
|
|
|
9
15
|
def initialize(message, provider:, model_id:, source_error_class:)
|
|
@@ -12,5 +18,43 @@ module Smith
|
|
|
12
18
|
@source_error_class = source_error_class.to_s.dup.freeze
|
|
13
19
|
super(message)
|
|
14
20
|
end
|
|
21
|
+
|
|
22
|
+
def details
|
|
23
|
+
{
|
|
24
|
+
provider: provider && DiagnosticText.capture(provider.to_s, max_bytes: MAX_DETAIL_BYTES),
|
|
25
|
+
model_id: DiagnosticText.capture(model_id, max_bytes: MAX_DETAIL_BYTES),
|
|
26
|
+
source_error_class: DiagnosticText.capture(source_error_class, max_bytes: MAX_DETAIL_BYTES)
|
|
27
|
+
}.freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.from_details(details, message: nil)
|
|
31
|
+
new(message, **normalize_details(details))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.normalize_details(details)
|
|
35
|
+
raise ArgumentError, "provider failure details must be a Hash" unless details.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
values = {}
|
|
38
|
+
Hash.instance_method(:each_pair).bind_call(details) do |key, value|
|
|
39
|
+
name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
|
|
40
|
+
raise ArgumentError, "provider failure details contain an unknown attribute" unless DETAIL_NAMES.include?(name)
|
|
41
|
+
raise ArgumentError, "provider failure details contain a duplicate attribute" if values.key?(name)
|
|
42
|
+
|
|
43
|
+
values[name] = bounded_detail(name, value)
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "provider failure details are incomplete" unless values.length == DETAIL_NAMES.length
|
|
46
|
+
|
|
47
|
+
values
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.bounded_detail(name, value)
|
|
51
|
+
return value if name == :provider && value.nil?
|
|
52
|
+
|
|
53
|
+
bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
|
|
54
|
+
raise ArgumentError, "provider failure detail #{name} must be bounded text" unless bounded
|
|
55
|
+
|
|
56
|
+
value
|
|
57
|
+
end
|
|
58
|
+
private_class_method :normalize_details, :bounded_detail
|
|
15
59
|
end
|
|
16
60
|
end
|
data/lib/smith/tool/capture.rb
CHANGED
|
@@ -54,8 +54,16 @@ module Smith
|
|
|
54
54
|
capture_failure(e, strict, reason: :capture_block_failed)
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# tool_call_id is additive only when present (a provider-batch
|
|
58
|
+
# invocation is ambient), so captures from direct invocations and
|
|
59
|
+
# pre-existing persisted payloads keep their exact two-key shape.
|
|
57
60
|
def append_capture(collector, captured, strict)
|
|
58
|
-
|
|
61
|
+
return unless strict || captured
|
|
62
|
+
|
|
63
|
+
entry = { tool: name.to_s, captured: captured }
|
|
64
|
+
tool_call_id = self.class.current_invocation&.tool_call_id
|
|
65
|
+
entry[:tool_call_id] = tool_call_id if tool_call_id
|
|
66
|
+
collector.call(entry)
|
|
59
67
|
rescue StandardError => e
|
|
60
68
|
capture_failure(e, strict, reason: :collector_failed)
|
|
61
69
|
end
|
data/lib/smith/tool.rb
CHANGED
|
@@ -165,9 +165,18 @@ module Smith
|
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
def emit_tool_trace(kwargs, result, duration)
|
|
168
|
+
# tool_call_id is the provider's correlation id for this invocation
|
|
169
|
+
# (present only when the call came from a provider batch); it lets a
|
|
170
|
+
# host join this trace to its own per-invocation records.
|
|
168
171
|
Smith::Trace.record(
|
|
169
172
|
type: :tool_call,
|
|
170
|
-
data: {
|
|
173
|
+
data: {
|
|
174
|
+
tool: name,
|
|
175
|
+
args: kwargs,
|
|
176
|
+
result: result,
|
|
177
|
+
duration: duration,
|
|
178
|
+
tool_call_id: self.class.current_invocation&.tool_call_id
|
|
179
|
+
}.compact,
|
|
171
180
|
sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
|
|
172
181
|
)
|
|
173
182
|
end
|
data/lib/smith/trace/logger.rb
CHANGED
data/lib/smith/trace/memory.rb
CHANGED
|
@@ -7,26 +7,62 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
10
11
|
cost: :trace_cost,
|
|
11
12
|
normalizer_decision: :trace_normalizer
|
|
12
13
|
}.freeze
|
|
13
14
|
|
|
14
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
# Generous enough that test and development runs never hit it; a bound
|
|
18
|
+
# exists at all so a long-lived process with parallel branches cannot
|
|
19
|
+
# grow this adapter without limit.
|
|
20
|
+
DEFAULT_LIMIT = 10_000
|
|
17
21
|
|
|
18
|
-
|
|
22
|
+
attr_reader :traces, :limit
|
|
23
|
+
|
|
24
|
+
def initialize(limit: DEFAULT_LIMIT)
|
|
25
|
+
unless limit.is_a?(Integer) && limit.positive?
|
|
26
|
+
raise ArgumentError, "Smith::Trace::Memory limit must be a positive integer, got #{limit.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@limit = limit
|
|
19
30
|
@traces = []
|
|
31
|
+
@dropped_count = 0
|
|
32
|
+
@mutex = Mutex.new
|
|
20
33
|
end
|
|
21
34
|
|
|
22
35
|
def record(type:, data:)
|
|
23
36
|
return unless type_enabled?(type)
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
entry = { type: type, data: filter_content(data) }
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
if @traces.length >= @limit
|
|
41
|
+
@dropped_count += 1
|
|
42
|
+
else
|
|
43
|
+
@traces << entry
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Entries rejected because the adapter was full. Zero in any healthy
|
|
49
|
+
# test run; a growing value means the limit needs raising or the
|
|
50
|
+
# process needs a clear!.
|
|
51
|
+
def dropped_count
|
|
52
|
+
@mutex.synchronize { @dropped_count }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A consistent copy for readers that may race concurrent recording;
|
|
56
|
+
# #traces stays the live array for compatibility.
|
|
57
|
+
def snapshot
|
|
58
|
+
@mutex.synchronize { @traces.dup }
|
|
26
59
|
end
|
|
27
60
|
|
|
28
61
|
def clear!
|
|
29
|
-
@
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@traces = []
|
|
64
|
+
@dropped_count = 0
|
|
65
|
+
end
|
|
30
66
|
end
|
|
31
67
|
|
|
32
68
|
private
|
|
@@ -7,7 +7,9 @@ module Smith
|
|
|
7
7
|
transition: :trace_transitions,
|
|
8
8
|
tool_call: :trace_tool_calls,
|
|
9
9
|
token_usage: :trace_token_usage,
|
|
10
|
-
|
|
10
|
+
provider_call: :trace_provider_calls,
|
|
11
|
+
cost: :trace_cost,
|
|
12
|
+
normalizer_decision: :trace_normalizer
|
|
11
13
|
}.freeze
|
|
12
14
|
|
|
13
15
|
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
@@ -23,18 +25,60 @@ module Smith
|
|
|
23
25
|
)
|
|
24
26
|
end
|
|
25
27
|
|
|
28
|
+
# Smith trace events describe operations that already finished, so the
|
|
29
|
+
# span is created retroactively: when the event carries a duration
|
|
30
|
+
# (:tool_call seconds, :provider_call milliseconds) the span's start is
|
|
31
|
+
# backdated by it and the span duration is real; otherwise the span is
|
|
32
|
+
# an instant. Uses only the documented opentelemetry-api surface
|
|
33
|
+
# (Tracer#start_span with start_timestamp, Span#finish with
|
|
34
|
+
# end_timestamp) so any SDK the host installs applies.
|
|
26
35
|
def record(type:, data:)
|
|
27
36
|
return unless @tracer
|
|
28
37
|
return unless type_enabled?(type)
|
|
29
38
|
|
|
30
39
|
filtered = filter_content(data)
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
finished_at = Time.now
|
|
41
|
+
span = @tracer.start_span("smith.#{type}", start_timestamp: span_start(filtered, finished_at))
|
|
42
|
+
begin
|
|
43
|
+
apply_attributes(span, filtered)
|
|
44
|
+
ensure
|
|
45
|
+
span.finish(end_timestamp: finished_at)
|
|
33
46
|
end
|
|
34
47
|
end
|
|
35
48
|
|
|
36
49
|
private
|
|
37
50
|
|
|
51
|
+
def span_start(data, finished_at)
|
|
52
|
+
seconds = duration_seconds(data)
|
|
53
|
+
seconds ? finished_at - seconds : finished_at
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def duration_seconds(data)
|
|
57
|
+
return data[:duration].to_f if data[:duration].is_a?(Numeric)
|
|
58
|
+
return data[:duration_ms] / 1000.0 if data[:duration_ms].is_a?(Numeric)
|
|
59
|
+
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def apply_attributes(span, data)
|
|
64
|
+
data.each do |key, value|
|
|
65
|
+
coerced = attribute_value(value)
|
|
66
|
+
span.set_attribute("smith.#{key}", coerced) unless coerced.nil?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# OpenTelemetry attributes accept strings, integers, floats, and
|
|
71
|
+
# booleans; numeric values keep their type instead of arriving as
|
|
72
|
+
# strings, everything else (symbols included) becomes a string, nil
|
|
73
|
+
# drops.
|
|
74
|
+
def attribute_value(value)
|
|
75
|
+
case value
|
|
76
|
+
when String, Integer, Float, true, false then value
|
|
77
|
+
when nil then nil
|
|
78
|
+
else value.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
38
82
|
def type_enabled?(type)
|
|
39
83
|
config_key = CONFIG_MAP[type]
|
|
40
84
|
return true unless config_key
|
data/lib/smith/trace.rb
CHANGED
|
@@ -1,34 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "attribution"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
module Trace
|
|
5
7
|
SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
|
|
8
|
+
ADAPTER_MUTEX = Mutex.new
|
|
9
|
+
private_constant :ADAPTER_MUTEX
|
|
6
10
|
|
|
7
11
|
def self.record(type:, data:, sensitivity: :low)
|
|
8
12
|
adapter = resolve_adapter
|
|
9
13
|
return unless adapter
|
|
10
14
|
|
|
11
|
-
filtered = apply_content_policy(data, sensitivity)
|
|
15
|
+
filtered = apply_content_policy(attributed(data), sensitivity)
|
|
12
16
|
filtered = filter_fields(type, filtered)
|
|
13
17
|
adapter.record(type: type, data: filtered)
|
|
14
18
|
rescue StandardError => e
|
|
15
19
|
Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
|
|
16
20
|
end
|
|
17
21
|
|
|
22
|
+
# Ambient attribution keys are identifiers, not content: they merge in
|
|
23
|
+
# under the caller's own keys (the caller wins on conflict) and then pass
|
|
24
|
+
# through the same content policy and field allowlist as everything else,
|
|
25
|
+
# so a host's configured trace_fields contract keeps holding.
|
|
26
|
+
def self.attributed(data)
|
|
27
|
+
return data unless Smith.config.trace_attribution
|
|
28
|
+
|
|
29
|
+
fields = Attribution.current_fields
|
|
30
|
+
return data if fields.empty?
|
|
31
|
+
|
|
32
|
+
fields.merge(data)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Class-configured adapters memoize one instance under a mutex so
|
|
36
|
+
# concurrent first records (every fan-out branch emits) share a single
|
|
37
|
+
# adapter instead of racing separate instances and losing entries.
|
|
18
38
|
def self.resolve_adapter
|
|
19
39
|
configured = Smith.config.trace_adapter
|
|
20
40
|
return nil unless configured
|
|
41
|
+
return configured unless configured.is_a?(Class)
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
ADAPTER_MUTEX.synchronize do
|
|
23
44
|
@adapter_instances ||= {}
|
|
24
45
|
@adapter_instances[configured] ||= configured.new
|
|
25
|
-
else
|
|
26
|
-
configured
|
|
27
46
|
end
|
|
28
47
|
end
|
|
29
48
|
|
|
30
49
|
def self.reset!
|
|
31
|
-
@adapter_instances = nil
|
|
50
|
+
ADAPTER_MUTEX.synchronize { @adapter_instances = nil }
|
|
32
51
|
end
|
|
33
52
|
|
|
34
53
|
def self.apply_content_policy(data, sensitivity)
|
data/lib/smith/version.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Smith
|
|
|
22
22
|
return nil unless ledger
|
|
23
23
|
|
|
24
24
|
ledger.limits.each_with_object({}) do |(dim, _limit), est|
|
|
25
|
-
per_branch = estimate_for_dimension(dim, ledger.
|
|
25
|
+
per_branch = estimate_for_dimension(dim, ledger.remaining_share(dim, branch_count))
|
|
26
26
|
cap = agent_cap_for_dimension(dim, agent_budget)
|
|
27
27
|
est[dim] = cap ? [per_branch, cap].min : per_branch
|
|
28
28
|
end
|
|
@@ -117,10 +117,10 @@ module Smith
|
|
|
117
117
|
{ branch: branch_key, agent: agent_name, output: agent_result ? agent_result.content : result }
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
-
def estimate_for_dimension(dim,
|
|
120
|
+
def estimate_for_dimension(dim, share)
|
|
121
121
|
return 0 unless BUDGET_DIMENSIONS.include?(dim)
|
|
122
122
|
|
|
123
|
-
TOKEN_DIMENSIONS.include?(dim) ? [
|
|
123
|
+
TOKEN_DIMENSIONS.include?(dim) ? [share, 1].max : share
|
|
124
124
|
end
|
|
125
125
|
|
|
126
126
|
def agent_cap_for_dimension(dim, agent_budget)
|
|
@@ -4,19 +4,32 @@ require_relative "../../types"
|
|
|
4
4
|
require_relative "../../budget/decimal_context"
|
|
5
5
|
require_relative "../message_value_normalizer"
|
|
6
6
|
require_relative "../prepared_step"
|
|
7
|
+
require_relative "../usage_entry"
|
|
7
8
|
require_relative "payload"
|
|
8
9
|
|
|
9
10
|
module Smith
|
|
10
11
|
class Workflow
|
|
11
12
|
module Composite
|
|
12
|
-
|
|
13
|
+
# Length is fail-closed value validation, not logic: every key the
|
|
14
|
+
# contract admits gets a bounded value check beside the contract that
|
|
15
|
+
# admits it. Splitting the checks away from the payload they guard
|
|
16
|
+
# would trade cohesion for a metric.
|
|
17
|
+
class Effects < Payload # rubocop:disable Metrics/ClassLength
|
|
13
18
|
attr_reader :total_tokens, :total_cost
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
# Required keys are the pre-attribution UsageEntry shape, so effects
|
|
21
|
+
# produced by an older Smith (a mid-deploy branch worker or a
|
|
22
|
+
# restored checkpoint) stay valid; allowed keys are the current
|
|
23
|
+
# member set, so unknown keys still reject. The attribution members
|
|
24
|
+
# (transition, branch_key, round, attempt_id) are optional by
|
|
25
|
+
# construction.
|
|
26
|
+
USAGE_ALLOWED_ATTRIBUTES = Workflow::UsageEntry.members.map(&:to_s).freeze
|
|
27
|
+
USAGE_REQUIRED_ATTRIBUTES =
|
|
28
|
+
(USAGE_ALLOWED_ATTRIBUTES - %w[transition branch_key round attempt_id workflow]).freeze
|
|
29
|
+
TOOL_REQUIRED_ATTRIBUTES = %w[tool captured].freeze
|
|
30
|
+
TOOL_ALLOWED_ATTRIBUTES = (TOOL_REQUIRED_ATTRIBUTES + %w[tool_call_id]).freeze
|
|
31
|
+
private_constant :USAGE_ALLOWED_ATTRIBUTES, :USAGE_REQUIRED_ATTRIBUTES,
|
|
32
|
+
:TOOL_REQUIRED_ATTRIBUTES, :TOOL_ALLOWED_ATTRIBUTES
|
|
20
33
|
|
|
21
34
|
attribute :usage_entries, Types::Array
|
|
22
35
|
attribute :tool_results, Types::Array
|
|
@@ -25,17 +38,12 @@ module Smith
|
|
|
25
38
|
def initialize(attributes)
|
|
26
39
|
owned = self.class.normalize_attributes(attributes)
|
|
27
40
|
normalized = MessageValueNormalizer.new(owned, label: "composite effects").call
|
|
28
|
-
usage_entries =
|
|
29
|
-
|
|
30
|
-
budget_consumed = normalized.fetch("budget_consumed")
|
|
41
|
+
usage_entries, tool_results, budget_consumed =
|
|
42
|
+
normalized.values_at("usage_entries", "tool_results", "budget_consumed")
|
|
31
43
|
@total_tokens, @total_cost = validate_usage_entries!(usage_entries)
|
|
32
44
|
validate_tool_results!(tool_results)
|
|
33
45
|
validate_budget!(budget_consumed)
|
|
34
|
-
super(
|
|
35
|
-
usage_entries:,
|
|
36
|
-
tool_results:,
|
|
37
|
-
budget_consumed:
|
|
38
|
-
)
|
|
46
|
+
super(usage_entries:, tool_results:, budget_consumed:)
|
|
39
47
|
end
|
|
40
48
|
|
|
41
49
|
private
|
|
@@ -44,8 +52,9 @@ module Smith
|
|
|
44
52
|
raise ArgumentError, "composite usage entries must be an Array" unless entries.is_a?(Array)
|
|
45
53
|
|
|
46
54
|
entries.each do |entry|
|
|
47
|
-
|
|
55
|
+
validate_bounded_keys!(entry, USAGE_REQUIRED_ATTRIBUTES, USAGE_ALLOWED_ATTRIBUTES, "composite usage entry")
|
|
48
56
|
validate_usage_identity!(entry)
|
|
57
|
+
validate_usage_attribution!(entry)
|
|
49
58
|
validate_usage_amount!(entry.fetch("input_tokens"), "input_tokens")
|
|
50
59
|
validate_usage_amount!(entry.fetch("output_tokens"), "output_tokens")
|
|
51
60
|
validate_cost!(entry.fetch("cost"))
|
|
@@ -68,25 +77,40 @@ module Smith
|
|
|
68
77
|
end
|
|
69
78
|
|
|
70
79
|
def validate_usage_identity!(entry)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
validate_uuid!(entry.fetch("usage_id"), "composite usage entry usage_id")
|
|
81
|
+
# agent_name and provider are nil-allowed; any other value
|
|
82
|
+
# (false included) must be a non-empty String.
|
|
83
|
+
%w[agent_name provider].each do |key|
|
|
84
|
+
value = entry.fetch(key)
|
|
85
|
+
validate_nonempty_string!(value, "composite usage entry #{key}") unless value.nil?
|
|
86
|
+
end
|
|
75
87
|
%w[model attempt_kind recorded_at].each do |key|
|
|
76
88
|
validate_nonempty_string!(entry.fetch(key), "composite usage entry #{key}")
|
|
77
89
|
end
|
|
78
90
|
end
|
|
79
91
|
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
# The optional attribution keys are bounded values, not just bounded
|
|
93
|
+
# keys: a present key with a wrong-typed, empty, or oversized value
|
|
94
|
+
# rejects exactly like the identity fields do. Absent keys (older
|
|
95
|
+
# producers, or nil-omitting serialization) stay valid.
|
|
96
|
+
def validate_usage_attribution!(entry)
|
|
97
|
+
%w[transition branch_key workflow].each do |key|
|
|
98
|
+
validate_bounded_string!(entry.fetch(key), "composite usage entry #{key}", 256) if entry.key?(key)
|
|
99
|
+
end
|
|
100
|
+
validate_usage_amount!(entry.fetch("round"), "round") if entry.key?("round")
|
|
101
|
+
validate_uuid!(entry.fetch("attempt_id"), "composite usage entry attempt_id") if entry.key?("attempt_id")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def validate_uuid!(value, label)
|
|
105
|
+
return if value.is_a?(String) && PreparedStep::UUID_PATTERN.match?(value)
|
|
82
106
|
|
|
83
|
-
raise ArgumentError, "
|
|
107
|
+
raise ArgumentError, "#{label} must be a UUID"
|
|
84
108
|
end
|
|
85
109
|
|
|
86
|
-
def
|
|
87
|
-
return if
|
|
110
|
+
def validate_bounded_string!(value, label, max_length)
|
|
111
|
+
return if value.is_a?(String) && value.length.between?(1, max_length)
|
|
88
112
|
|
|
89
|
-
|
|
113
|
+
raise ArgumentError, "#{label} must be a bounded non-empty String"
|
|
90
114
|
end
|
|
91
115
|
|
|
92
116
|
def validate_nonempty_string!(value, label)
|
|
@@ -102,8 +126,7 @@ module Smith
|
|
|
102
126
|
end
|
|
103
127
|
|
|
104
128
|
def validate_cost!(cost)
|
|
105
|
-
return if cost.nil?
|
|
106
|
-
return if cost.is_a?(Numeric) && cost.finite? && cost >= 0
|
|
129
|
+
return if cost.nil? || (cost.is_a?(Numeric) && cost.finite? && cost >= 0)
|
|
107
130
|
|
|
108
131
|
raise ArgumentError, "composite usage entry cost must be a finite non-negative number or nil"
|
|
109
132
|
end
|
|
@@ -112,10 +135,15 @@ module Smith
|
|
|
112
135
|
raise ArgumentError, "composite tool results must be an Array" unless entries.is_a?(Array)
|
|
113
136
|
|
|
114
137
|
entries.each do |entry|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
validate_bounded_keys!(entry, TOOL_REQUIRED_ATTRIBUTES, TOOL_ALLOWED_ATTRIBUTES, "composite tool result")
|
|
139
|
+
validate_bounded_string!(entry.fetch("tool"), "composite tool result tool", 256)
|
|
140
|
+
|
|
141
|
+
# Present only for provider-batch invocations; the producer never
|
|
142
|
+
# writes a nil, so a present key must carry a real id. Provider
|
|
143
|
+
# tool-call ids are short strings; 1024 is far above any observed
|
|
144
|
+
# provider format while still bounding the payload.
|
|
145
|
+
if entry.key?("tool_call_id")
|
|
146
|
+
validate_bounded_string!(entry.fetch("tool_call_id"), "composite tool result tool_call_id", 1024)
|
|
119
147
|
end
|
|
120
148
|
end
|
|
121
149
|
end
|
|
@@ -130,9 +158,12 @@ module Smith
|
|
|
130
158
|
end
|
|
131
159
|
end
|
|
132
160
|
|
|
133
|
-
|
|
161
|
+
# Every required key present, no key outside the allowed set: older
|
|
162
|
+
# producers (missing optional keys) pass, unknown keys still reject.
|
|
163
|
+
# Passing the same set for both is an exact-keys check.
|
|
164
|
+
def validate_bounded_keys!(value, required, allowed, label)
|
|
134
165
|
raise ArgumentError, "#{label} must be a Hash" unless value.is_a?(Hash)
|
|
135
|
-
return if value.keys.
|
|
166
|
+
return if (required - value.keys).empty? && (value.keys - allowed).empty?
|
|
136
167
|
|
|
137
168
|
raise ArgumentError, "#{label} attributes are invalid"
|
|
138
169
|
end
|
|
@@ -29,7 +29,8 @@ module Smith
|
|
|
29
29
|
session_messages: snapshot_value(@session_messages || []),
|
|
30
30
|
tool_results: snapshot_value(@tool_results || []),
|
|
31
31
|
state: @state,
|
|
32
|
-
transition: transition
|
|
32
|
+
transition: transition,
|
|
33
|
+
last_agent_execution: snapshot_value(@last_agent_execution)
|
|
33
34
|
)
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -37,7 +38,7 @@ module Smith
|
|
|
37
38
|
@context.merge!(step.context_writes)
|
|
38
39
|
step.context_writes.each_key { |key| record_persisted_key!(key) }
|
|
39
40
|
@router_next_transition = step.routed_to if step.routed_to
|
|
40
|
-
@outcome = snapshot_value(step.outcome) if step.outcome
|
|
41
|
+
@outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
|
|
41
42
|
end
|
|
42
43
|
|
|
43
44
|
def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
|
|
@@ -15,15 +15,28 @@ module Smith
|
|
|
15
15
|
@current_state = state
|
|
16
16
|
@transition_name = transition ? transition.name : options.fetch(:transition_name)
|
|
17
17
|
@allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
|
|
18
|
+
@last_agent_execution = options[:last_agent_execution]
|
|
18
19
|
@context_writes = {}
|
|
19
20
|
@routed_to = nil
|
|
20
21
|
@outcome = nil
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# The model id / provider that actually served the most recent serial
|
|
25
|
+
# `execute :agent` step (post fallback resolution), or nil if no serial
|
|
26
|
+
# agent step has run. Symmetric with `last_output`, which returns that
|
|
27
|
+
# step's content; use these to attribute an agent output to its model.
|
|
28
|
+
def last_agent_model
|
|
29
|
+
@last_agent_execution && @last_agent_execution[:model]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def last_agent_provider
|
|
33
|
+
@last_agent_execution && @last_agent_execution[:provider]
|
|
34
|
+
end
|
|
35
|
+
|
|
23
36
|
def last_output
|
|
24
37
|
return @last_output if defined?(@last_output)
|
|
25
38
|
|
|
26
|
-
msg = session_messages.reverse.find { |m| m[:role]
|
|
39
|
+
msg = session_messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "assistant" }
|
|
27
40
|
@last_output = msg && (msg[:content] || msg["content"])
|
|
28
41
|
end
|
|
29
42
|
|
|
@@ -59,7 +72,7 @@ module Smith
|
|
|
59
72
|
private
|
|
60
73
|
|
|
61
74
|
def validate_options!(options)
|
|
62
|
-
unknown = options.keys - %i[transition transition_name allowed_routes]
|
|
75
|
+
unknown = options.keys - %i[transition transition_name allowed_routes last_agent_execution]
|
|
63
76
|
raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
|
|
64
77
|
return if options[:transition] || options.key?(:transition_name)
|
|
65
78
|
|
data/lib/smith/workflow/dsl.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
class Workflow
|
|
5
5
|
module DSL
|
|
6
|
+
BUDGET_KEYS = %i[total_tokens token_limit total_cost tool_calls wall_clock].freeze
|
|
7
|
+
private_constant :BUDGET_KEYS
|
|
8
|
+
|
|
6
9
|
def self.included(base)
|
|
7
10
|
base.extend(ClassMethods)
|
|
8
11
|
end
|
|
@@ -17,6 +20,7 @@ module Smith
|
|
|
17
20
|
duplicate_transition_index(@transitions_by_state)
|
|
18
21
|
)
|
|
19
22
|
subclass.instance_variable_set(:@transition_order, (@transition_order || {}).dup)
|
|
23
|
+
subclass.instance_variable_set(:@generated_transitions, (@generated_transitions || []).dup)
|
|
20
24
|
subclass.instance_variable_set(:@transition_sequence, @transition_sequence)
|
|
21
25
|
subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
|
|
22
26
|
subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
|
|
@@ -50,7 +54,12 @@ module Smith
|
|
|
50
54
|
declared = Transition.new(name, from: from, to: to, &)
|
|
51
55
|
name = declared.name
|
|
52
56
|
@transitions ||= {}
|
|
53
|
-
|
|
57
|
+
|
|
58
|
+
if @transitions.key?(name)
|
|
59
|
+
remove_from_transition_index(@transitions[name])
|
|
60
|
+
release_generated_transition_order(name)
|
|
61
|
+
end
|
|
62
|
+
|
|
54
63
|
@transitions[name] = declared
|
|
55
64
|
insert_into_transition_index(declared)
|
|
56
65
|
end
|
|
@@ -58,6 +67,12 @@ module Smith
|
|
|
58
67
|
def budget(**opts)
|
|
59
68
|
return @budget_config if opts.empty?
|
|
60
69
|
|
|
70
|
+
unknown = opts.keys - BUDGET_KEYS
|
|
71
|
+
unless unknown.empty?
|
|
72
|
+
raise ArgumentError, "workflow budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
|
|
73
|
+
"accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
|
|
74
|
+
end
|
|
75
|
+
|
|
61
76
|
@budget_config = opts
|
|
62
77
|
end
|
|
63
78
|
|
|
@@ -190,11 +205,12 @@ module Smith
|
|
|
190
205
|
#
|
|
191
206
|
# Wiring contract: when the resolved TTL is non-nil,
|
|
192
207
|
# Workflow#persist! forwards it to the adapter as a `ttl:`
|
|
193
|
-
# kwarg.
|
|
194
|
-
# ActiveRecordStore
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
#
|
|
208
|
+
# kwarg. Memory, RedisStore, and CacheStore honour it;
|
|
209
|
+
# ActiveRecordStore cannot expire state and refuses a non-nil TTL
|
|
210
|
+
# before writing, which a persisted run reaches before its first
|
|
211
|
+
# step. External duck-typed adapters that implement only the bare
|
|
212
|
+
# REQUIRED_METHODS contract without a `ttl:` kwarg will only break
|
|
213
|
+
# when a host actually opts into TTL.
|
|
198
214
|
def persistence_ttl(seconds = nil)
|
|
199
215
|
return @persistence_ttl if seconds.nil?
|
|
200
216
|
|
|
@@ -279,10 +295,28 @@ module Smith
|
|
|
279
295
|
transitions_by_state.delete(transition.from) if indexed.empty?
|
|
280
296
|
end
|
|
281
297
|
|
|
298
|
+
# A user redeclaration replacing a GENERATED transition takes a fresh
|
|
299
|
+
# declaration-position order; a genuine user redefinition keeps its
|
|
300
|
+
# original position (matrix-pinned redefinition semantics). Without
|
|
301
|
+
# this, the synthetic :fail generated when `state :failed` is
|
|
302
|
+
# declared keeps its early order number, and a later user-declared
|
|
303
|
+
# :fail sharing an origin state with a primary transition would sort
|
|
304
|
+
# ahead of it and shadow it at run time.
|
|
305
|
+
def release_generated_transition_order(name)
|
|
306
|
+
return unless generated_transitions.delete(name)
|
|
307
|
+
|
|
308
|
+
transition_order.delete(name)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def generated_transitions
|
|
312
|
+
@generated_transitions ||= []
|
|
313
|
+
end
|
|
314
|
+
|
|
282
315
|
def generate_fail_transition
|
|
283
316
|
@transitions ||= {}
|
|
284
317
|
return if @transitions.key?(:fail)
|
|
285
318
|
|
|
319
|
+
generated_transitions << :fail
|
|
286
320
|
transition(:fail, from: nil, to: :failed)
|
|
287
321
|
end
|
|
288
322
|
end
|
|
@@ -389,7 +389,7 @@ module Smith
|
|
|
389
389
|
def invoke_on_step_callback(step, callback)
|
|
390
390
|
callback&.call(step)
|
|
391
391
|
rescue StandardError => e
|
|
392
|
-
Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message}")
|
|
392
|
+
Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message} (#{e.class})")
|
|
393
393
|
end
|
|
394
394
|
end
|
|
395
395
|
end
|