smith-agents 0.10.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 +203 -0
- data/README.md +16 -6
- data/docs/CONFIGURATION.md +21 -1
- data/docs/PATTERNS.md +59 -0
- data/docs/PERSISTENCE.md +5 -1
- data/lib/smith/agent/fallback_configuration.rb +26 -3
- data/lib/smith/agent/provider_attempt.rb +11 -4
- data/lib/smith/agent/provider_call_timing.rb +62 -7
- data/lib/smith/agent/provider_completion.rb +8 -8
- data/lib/smith/agent/provider_usage.rb +6 -0
- data/lib/smith/agent/usage_tracking.rb +3 -0
- data/lib/smith/agent.rb +9 -1
- 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/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/version.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +3 -3
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +1 -1
- data/lib/smith/workflow/dsl.rb +15 -5
- data/lib/smith/workflow/durability.rb +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +108 -10
- data/lib/smith/workflow/execution.rb +2 -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/guardrail_integration.rb +2 -0
- data/lib/smith/workflow/optimization_state.rb +2 -2
- data/lib/smith/workflow/persistence.rb +26 -9
- data/lib/smith/workflow/retry_execution.rb +4 -0
- data/lib/smith/workflow/run_result.rb +10 -0
- data/lib/smith/workflow/step_completion.rb +4 -0
- data/lib/smith/workflow.rb +5 -3
- data/lib/smith.rb +2 -2
- metadata +1 -1
data/lib/smith/agent.rb
CHANGED
|
@@ -11,9 +11,10 @@ module Smith
|
|
|
11
11
|
EXECUTION_IDENTITY_UNSET = Object.new.freeze
|
|
12
12
|
TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
|
|
13
13
|
TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
|
|
14
|
+
BUDGET_KEYS = %i[token_limit cost wall_clock tool_calls total_tokens total_cost].freeze
|
|
14
15
|
|
|
15
16
|
private_constant :EXECUTION_IDENTITY_UNSET
|
|
16
|
-
private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
|
|
17
|
+
private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES, :BUDGET_KEYS
|
|
17
18
|
|
|
18
19
|
# Reserved input names auto-injected by the normalizer into
|
|
19
20
|
# runtime_context. User-side `inputs :name` calls cannot redeclare
|
|
@@ -30,6 +31,7 @@ module Smith
|
|
|
30
31
|
subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
|
|
31
32
|
subclass.instance_variable_set(:@data_volume, @data_volume)
|
|
32
33
|
subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
|
|
34
|
+
subclass.instance_variable_set(:@fallback_models_block, @fallback_models_block)
|
|
33
35
|
subclass.instance_variable_set(:@model_block, @model_block)
|
|
34
36
|
subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
|
|
35
37
|
subclass.instance_variable_set(:@execution_identity, nil)
|
|
@@ -48,6 +50,12 @@ module Smith
|
|
|
48
50
|
def budget(**opts)
|
|
49
51
|
return @budget_config if opts.empty?
|
|
50
52
|
|
|
53
|
+
unknown = opts.keys - BUDGET_KEYS
|
|
54
|
+
unless unknown.empty?
|
|
55
|
+
raise ArgumentError, "agent budget does not accept #{unknown.map(&:inspect).join(", ")}; " \
|
|
56
|
+
"accepted keys are #{BUDGET_KEYS.map(&:inspect).join(", ")}"
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
@budget_config = opts
|
|
52
60
|
end
|
|
53
61
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bigdecimal"
|
|
4
4
|
require "dry-initializer"
|
|
5
|
+
require_relative "decimal_context"
|
|
5
6
|
|
|
6
7
|
module Smith
|
|
7
8
|
module Budget
|
|
@@ -26,6 +27,25 @@ module Smith
|
|
|
26
27
|
finite_float!(amount)
|
|
27
28
|
end
|
|
28
29
|
|
|
30
|
+
# Remaining capacity, or one of parts equal shares of it, is reserved as
|
|
31
|
+
# read, so parts reservations of the external value must never exceed the
|
|
32
|
+
# exact amount. An Integer keeps floor division; a Float steps down until
|
|
33
|
+
# its decimal form fits, which takes O(1) steps because Float division
|
|
34
|
+
# lands within one ulp of the exact share.
|
|
35
|
+
def externalize_remaining(key, amount, parts = 1)
|
|
36
|
+
unless parts.is_a?(Integer) && parts.positive?
|
|
37
|
+
raise ArgumentError, "budget share parts must be a positive Integer"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
share = externalize_amount(key, amount) / parts
|
|
41
|
+
return share unless share.is_a?(Float)
|
|
42
|
+
|
|
43
|
+
DecimalContext.call do
|
|
44
|
+
share = share.prev_float while internalize_amount(share) * parts > amount
|
|
45
|
+
end
|
|
46
|
+
share
|
|
47
|
+
end
|
|
48
|
+
|
|
29
49
|
private
|
|
30
50
|
|
|
31
51
|
def internalize_amount(amount)
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -58,12 +58,16 @@ module Smith
|
|
|
58
58
|
@mutex.synchronize { release_reservation(token) }
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
def remaining(key)
|
|
61
|
+
def remaining(key) = remaining_share(key, 1)
|
|
62
|
+
|
|
63
|
+
# One of parts equal shares of the remaining amount, taken from the exact
|
|
64
|
+
# internal value so that parts reservations of it always fit.
|
|
65
|
+
def remaining_share(key, parts)
|
|
62
66
|
dimension = @amount_contract.dimension!(key)
|
|
63
67
|
@amount_contract.known_dimension!(dimension)
|
|
64
68
|
@mutex.synchronize do
|
|
65
69
|
amount = @state_transition.remaining(@publication.state, dimension)
|
|
66
|
-
@representation.
|
|
70
|
+
@representation.externalize_remaining(dimension, amount, parts)
|
|
67
71
|
end
|
|
68
72
|
end
|
|
69
73
|
|
|
@@ -6,19 +6,29 @@ module Smith
|
|
|
6
6
|
MARKER = "[smith:injected-state]"
|
|
7
7
|
|
|
8
8
|
def self.inject(messages, formatter:, persisted:)
|
|
9
|
-
|
|
9
|
+
text = formatter.call(persisted).to_s
|
|
10
|
+
content = "#{MARKER}\n#{text}"
|
|
10
11
|
|
|
11
12
|
existing_index = messages.index do |message|
|
|
12
13
|
message_content = message[:content] || message["content"]
|
|
13
14
|
message_content.is_a?(String) && message_content.start_with?(MARKER)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
|
-
if
|
|
17
|
+
if blank?(text)
|
|
18
|
+
without_marker(messages, existing_index)
|
|
19
|
+
elsif existing_index
|
|
17
20
|
messages.dup.tap { |msgs| msgs[existing_index] = { role: :system, content: content } }
|
|
18
21
|
else
|
|
19
22
|
messages + [{ role: :system, content: content }]
|
|
20
23
|
end
|
|
21
24
|
end
|
|
25
|
+
|
|
26
|
+
def self.blank?(text) = text.valid_encoding? && text.strip.empty?
|
|
27
|
+
|
|
28
|
+
def self.without_marker(messages, index)
|
|
29
|
+
index ? messages.dup.tap { |msgs| msgs.delete_at(index) } : messages
|
|
30
|
+
end
|
|
31
|
+
private_class_method :blank?, :without_marker
|
|
22
32
|
end
|
|
23
33
|
end
|
|
24
34
|
end
|
|
@@ -9,7 +9,10 @@ module Smith
|
|
|
9
9
|
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
10
10
|
STRING_BYTESLICE = String.instance_method(:byteslice)
|
|
11
11
|
MODULE_MATCH = Module.instance_method(:===)
|
|
12
|
-
|
|
12
|
+
MODULE_NAME = Module.instance_method(:name)
|
|
13
|
+
OBJECT_CLASS = Object.instance_method(:class)
|
|
14
|
+
private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH, :MODULE_NAME,
|
|
15
|
+
:OBJECT_CLASS
|
|
13
16
|
|
|
14
17
|
extend Dry::Initializer
|
|
15
18
|
|
|
@@ -18,6 +21,12 @@ module Smith
|
|
|
18
21
|
|
|
19
22
|
def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
|
|
20
23
|
|
|
24
|
+
# An error's class name, read through the native lookups so the error
|
|
25
|
+
# cannot misreport it, and bounded like any diagnostic identifier.
|
|
26
|
+
def self.error_class_name(error)
|
|
27
|
+
capture(MODULE_NAME.bind_call(OBJECT_CLASS.bind_call(error)) || "anonymous_error", max_bytes: 512)
|
|
28
|
+
end
|
|
29
|
+
|
|
21
30
|
def call
|
|
22
31
|
validate_limit!
|
|
23
32
|
text = utf8_prefix
|
|
@@ -31,10 +31,26 @@ module Smith
|
|
|
31
31
|
)
|
|
32
32
|
return
|
|
33
33
|
else
|
|
34
|
+
check_ttl_support(report, adapter)
|
|
34
35
|
check_persist_and_restore(report, adapter)
|
|
35
36
|
check_resume_after_restore(report, adapter)
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
# ActiveRecordStore refuses every write that carries a TTL, so a
|
|
40
|
+
# configured global TTL would fail the first persisted run.
|
|
41
|
+
def self.check_ttl_support(report, adapter)
|
|
42
|
+
ttl = ::Smith.config.persistence_ttl
|
|
43
|
+
return if ttl.nil? || !adapter.is_a?(::Smith::PersistenceAdapters::ActiveRecordStore)
|
|
44
|
+
|
|
45
|
+
report.add(
|
|
46
|
+
name: "durability.ttl",
|
|
47
|
+
status: :fail,
|
|
48
|
+
message: "ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil",
|
|
49
|
+
detail: "Smith.config.persistence_ttl is #{ttl.inspect}, so every persisted write refuses it. " \
|
|
50
|
+
"Set it to nil, or use an adapter that honours TTL."
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
38
54
|
def self.check_persist_and_restore(report, adapter)
|
|
39
55
|
workflow_class = build_probe_class
|
|
40
56
|
payload = JSON.generate(workflow_class.new.to_state)
|
|
@@ -17,7 +17,10 @@ module Smith
|
|
|
17
17
|
OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
|
|
18
18
|
|
|
19
19
|
def run(report)
|
|
20
|
-
adapter =
|
|
20
|
+
adapter = Smith.persistence_adapter
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
report_invalid_adapter(report, e)
|
|
23
|
+
else
|
|
21
24
|
return report_missing_adapter(report) if adapter.nil?
|
|
22
25
|
|
|
23
26
|
missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
|
|
@@ -26,12 +29,6 @@ module Smith
|
|
|
26
29
|
report_missing_capabilities(report, adapter, missing)
|
|
27
30
|
end
|
|
28
31
|
|
|
29
|
-
def resolve_adapter
|
|
30
|
-
Smith.persistence_adapter
|
|
31
|
-
rescue StandardError
|
|
32
|
-
nil
|
|
33
|
-
end
|
|
34
|
-
|
|
35
32
|
def capability_available?(adapter, capability)
|
|
36
33
|
return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
|
|
37
34
|
return true unless capability == :persistence_identity
|
|
@@ -42,6 +39,15 @@ module Smith
|
|
|
42
39
|
false
|
|
43
40
|
end
|
|
44
41
|
|
|
42
|
+
def report_invalid_adapter(report, error)
|
|
43
|
+
report.add(
|
|
44
|
+
name: "persistence.capabilities",
|
|
45
|
+
status: :fail,
|
|
46
|
+
message: "Persistence adapter configuration is invalid",
|
|
47
|
+
detail: error.message
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
45
51
|
def report_missing_adapter(report)
|
|
46
52
|
report.add(
|
|
47
53
|
name: "persistence.capabilities",
|
data/lib/smith/errors.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "diagnostic_text"
|
|
3
4
|
require_relative "error"
|
|
4
5
|
require_relative "persisted_failure_invalid"
|
|
5
6
|
require_relative "pricing_configuration_error"
|
|
@@ -88,6 +89,11 @@ module Smith
|
|
|
88
89
|
class AgentError < Error; end
|
|
89
90
|
|
|
90
91
|
class BlankAgentOutputError < AgentError
|
|
92
|
+
DETAIL_NAMES = %i[agent_name model_used].freeze
|
|
93
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
94
|
+
MAX_DETAIL_BYTES = 512
|
|
95
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS, :MAX_DETAIL_BYTES
|
|
96
|
+
|
|
91
97
|
attr_reader :agent_name, :model_used
|
|
92
98
|
|
|
93
99
|
def initialize(agent_name:, model_used:)
|
|
@@ -101,6 +107,46 @@ module Smith
|
|
|
101
107
|
|
|
102
108
|
super(detail)
|
|
103
109
|
end
|
|
110
|
+
|
|
111
|
+
def details
|
|
112
|
+
{
|
|
113
|
+
agent_name: agent_name && DiagnosticText.capture(agent_name.to_s, max_bytes: MAX_DETAIL_BYTES),
|
|
114
|
+
model_used: model_used && DiagnosticText.capture(model_used.to_s, max_bytes: MAX_DETAIL_BYTES)
|
|
115
|
+
}.freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.from_details(details)
|
|
119
|
+
values = normalize_details(details)
|
|
120
|
+
new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used))
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def self.normalize_details(details)
|
|
124
|
+
raise ArgumentError, "blank agent output details must be a Hash" unless details.is_a?(Hash)
|
|
125
|
+
|
|
126
|
+
values = {}
|
|
127
|
+
Hash.instance_method(:each_pair).bind_call(details) do |key, value|
|
|
128
|
+
name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
|
|
129
|
+
unless DETAIL_NAMES.include?(name)
|
|
130
|
+
raise ArgumentError, "blank agent output details contain an unknown attribute"
|
|
131
|
+
end
|
|
132
|
+
raise ArgumentError, "blank agent output details contain a duplicate attribute" if values.key?(name)
|
|
133
|
+
|
|
134
|
+
values[name] = bounded_detail(name, value)
|
|
135
|
+
end
|
|
136
|
+
raise ArgumentError, "blank agent output details are incomplete" unless values.length == DETAIL_NAMES.length
|
|
137
|
+
|
|
138
|
+
values
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def self.bounded_detail(name, value)
|
|
142
|
+
return value if value.nil?
|
|
143
|
+
|
|
144
|
+
bounded = value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_DETAIL_BYTES
|
|
145
|
+
raise ArgumentError, "blank agent output detail #{name} must be bounded text" unless bounded
|
|
146
|
+
|
|
147
|
+
value
|
|
148
|
+
end
|
|
149
|
+
private_class_method :normalize_details, :bounded_detail
|
|
104
150
|
end
|
|
105
151
|
|
|
106
152
|
class WorkflowError < Error; end
|
|
@@ -185,15 +231,20 @@ module Smith
|
|
|
185
231
|
# `idempotency_mode :strict`. Signals that a previous worker crashed
|
|
186
232
|
# between `persist!` (before advance) and `persist!` (after advance);
|
|
187
233
|
# the step's effects are unknown, so blindly re-running could
|
|
188
|
-
# double-execute non-idempotent agent calls or tools.
|
|
234
|
+
# double-execute non-idempotent agent calls or tools. `state` is the
|
|
235
|
+
# persisted state the interrupted step started from and `transition` the
|
|
236
|
+
# next transition the payload records; each is nil when unknown.
|
|
189
237
|
class StepInProgressOnRestore < Error
|
|
190
|
-
attr_reader :workflow, :persistence_key
|
|
238
|
+
attr_reader :workflow, :persistence_key, :state, :transition
|
|
191
239
|
|
|
192
|
-
def initialize(workflow:, persistence_key:)
|
|
240
|
+
def initialize(workflow:, persistence_key:, state: nil, transition: nil)
|
|
193
241
|
@workflow = workflow
|
|
194
242
|
@persistence_key = persistence_key
|
|
243
|
+
@state = state.to_sym if state.is_a?(String) || state.is_a?(Symbol)
|
|
244
|
+
@transition = transition.to_sym if transition.is_a?(String) || transition.is_a?(Symbol)
|
|
195
245
|
super(
|
|
196
|
-
"step in progress on restore for #{workflow} key=#{persistence_key.inspect}
|
|
246
|
+
"step in progress on restore for #{workflow} key=#{persistence_key.inspect}" \
|
|
247
|
+
"#{" state=#{@state.inspect}" if @state}: " \
|
|
197
248
|
"a previous worker crashed mid-step. Hosts using idempotency_mode :strict must " \
|
|
198
249
|
"decide whether to clear the persisted state (idempotent re-run unsafe) or " \
|
|
199
250
|
"switch to :lax (assume re-run is safe)."
|
|
@@ -5,9 +5,8 @@ module Smith
|
|
|
5
5
|
module ActiveRecordExactStore
|
|
6
6
|
attr_reader :persistence_identity
|
|
7
7
|
|
|
8
|
-
def replace_exact(key, payload, expected_payload:, ttl: nil)
|
|
9
|
-
|
|
10
|
-
ensure_locking_configuration!
|
|
8
|
+
def replace_exact(key, payload, expected_payload:, ttl: nil)
|
|
9
|
+
ensure_versioned_write!(ttl)
|
|
11
10
|
model = model_class
|
|
12
11
|
ensure_exact_schema!(model)
|
|
13
12
|
ActiveRecordExactWrite.new(
|
|
@@ -25,6 +24,12 @@ module Smith
|
|
|
25
24
|
|
|
26
25
|
private
|
|
27
26
|
|
|
27
|
+
def ensure_versioned_write!(ttl)
|
|
28
|
+
PersistenceAdapters.refuse_active_record_ttl!(ttl)
|
|
29
|
+
ensure_version_column!
|
|
30
|
+
ensure_locking_configuration!
|
|
31
|
+
end
|
|
32
|
+
|
|
28
33
|
def ensure_exact_schema!(model)
|
|
29
34
|
ensure_exact_payload_column!(model)
|
|
30
35
|
ensure_exact_key_uniqueness!(model)
|
|
@@ -16,10 +16,8 @@ module Smith
|
|
|
16
16
|
@persistence_identity = identity.to_s.dup.freeze if identity
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def store(key, payload, ttl: nil)
|
|
20
|
-
|
|
21
|
-
# `expires_at` column + a periodic sweeper job. Ignored here;
|
|
22
|
-
# documented as a known limitation.
|
|
19
|
+
def store(key, payload, ttl: nil)
|
|
20
|
+
PersistenceAdapters.refuse_active_record_ttl!(ttl)
|
|
23
21
|
Retry.with_retries(operation: :store, transient: ActiveRecordConnectionErrors.classes) do
|
|
24
22
|
record = model_class.find_or_initialize_by(@key_column => key)
|
|
25
23
|
record.public_send(:"#{@payload_column}=", payload)
|
|
@@ -47,9 +45,8 @@ module Smith
|
|
|
47
45
|
# `lock_version` column. Requires the AR model to have a
|
|
48
46
|
# `lock_version` (or configured) integer column with default 0.
|
|
49
47
|
# If absent, raises ArgumentError directing the host to migrate.
|
|
50
|
-
def store_versioned(key, payload, expected_version:, ttl: nil)
|
|
51
|
-
|
|
52
|
-
ensure_locking_configuration!
|
|
48
|
+
def store_versioned(key, payload, expected_version:, ttl: nil)
|
|
49
|
+
ensure_versioned_write!(ttl)
|
|
53
50
|
|
|
54
51
|
write_versioned(key, payload, expected_version)
|
|
55
52
|
rescue *ActiveRecordConnectionErrors.classes => e
|
|
@@ -71,6 +71,16 @@ module Smith
|
|
|
71
71
|
end
|
|
72
72
|
private_class_method :build
|
|
73
73
|
|
|
74
|
+
# ActiveRecordStore's host table has no expiry column, so a TTL could
|
|
75
|
+
# only be ignored. Its writes fail closed instead of silently keeping
|
|
76
|
+
# state forever; reads and deletes never take a TTL and keep working.
|
|
77
|
+
def self.refuse_active_record_ttl!(ttl)
|
|
78
|
+
return if ttl.nil?
|
|
79
|
+
|
|
80
|
+
raise ArgumentError,
|
|
81
|
+
"ActiveRecordStore cannot expire workflow state; persistence_ttl must be nil, got #{ttl.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
74
84
|
# Capability introspection used by Workflow#persist! to decide
|
|
75
85
|
# whether the adapter supports optimistic locking via store_versioned.
|
|
76
86
|
def self.supports?(adapter, capability)
|
|
@@ -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/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)
|
|
@@ -38,7 +38,7 @@ module Smith
|
|
|
38
38
|
@context.merge!(step.context_writes)
|
|
39
39
|
step.context_writes.each_key { |key| record_persisted_key!(key) }
|
|
40
40
|
@router_next_transition = step.routed_to if step.routed_to
|
|
41
|
-
@outcome = snapshot_value(step.outcome) if step.outcome
|
|
41
|
+
@outcome = symbolize_value(snapshot_value(step.outcome)) if step.outcome
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
|
|
@@ -36,7 +36,7 @@ module Smith
|
|
|
36
36
|
def last_output
|
|
37
37
|
return @last_output if defined?(@last_output)
|
|
38
38
|
|
|
39
|
-
msg = session_messages.reverse.find { |m| m[:role]
|
|
39
|
+
msg = session_messages.reverse.find { |m| (m[:role] || m["role"]).to_s == "assistant" }
|
|
40
40
|
@last_output = msg && (msg[:content] || msg["content"])
|
|
41
41
|
end
|
|
42
42
|
|
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
|
|
@@ -64,6 +67,12 @@ module Smith
|
|
|
64
67
|
def budget(**opts)
|
|
65
68
|
return @budget_config if opts.empty?
|
|
66
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
|
+
|
|
67
76
|
@budget_config = opts
|
|
68
77
|
end
|
|
69
78
|
|
|
@@ -196,11 +205,12 @@ module Smith
|
|
|
196
205
|
#
|
|
197
206
|
# Wiring contract: when the resolved TTL is non-nil,
|
|
198
207
|
# Workflow#persist! forwards it to the adapter as a `ttl:`
|
|
199
|
-
# kwarg.
|
|
200
|
-
# ActiveRecordStore
|
|
201
|
-
#
|
|
202
|
-
#
|
|
203
|
-
#
|
|
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.
|
|
204
214
|
def persistence_ttl(seconds = nil)
|
|
205
215
|
return @persistence_ttl if seconds.nil?
|
|
206
216
|
|
|
@@ -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
|