smith-agents 0.4.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 +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
data/lib/smith/trace.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Trace
|
|
5
|
+
SENSITIVITY_CONTENT_KEYS = %i[args result].freeze
|
|
6
|
+
|
|
7
|
+
def self.record(type:, data:, sensitivity: :low)
|
|
8
|
+
adapter = resolve_adapter
|
|
9
|
+
return unless adapter
|
|
10
|
+
|
|
11
|
+
filtered = apply_content_policy(data, sensitivity)
|
|
12
|
+
filtered = filter_fields(type, filtered)
|
|
13
|
+
adapter.record(type: type, data: filtered)
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Smith.config.logger&.error("Smith::Trace adapter error: #{e.message}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.resolve_adapter
|
|
19
|
+
configured = Smith.config.trace_adapter
|
|
20
|
+
return nil unless configured
|
|
21
|
+
|
|
22
|
+
if configured.is_a?(Class)
|
|
23
|
+
@adapter_instances ||= {}
|
|
24
|
+
@adapter_instances[configured] ||= configured.new
|
|
25
|
+
else
|
|
26
|
+
configured
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.reset!
|
|
31
|
+
@adapter_instances = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.apply_content_policy(data, sensitivity)
|
|
35
|
+
case Smith.config.trace_content
|
|
36
|
+
when true
|
|
37
|
+
apply_sensitivity(data, sensitivity)
|
|
38
|
+
when :redacted
|
|
39
|
+
apply_sensitivity(redact_sensitive_keys(data), sensitivity)
|
|
40
|
+
else
|
|
41
|
+
data.except(*SENSITIVITY_CONTENT_KEYS)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.apply_sensitivity(data, sensitivity)
|
|
46
|
+
case sensitivity
|
|
47
|
+
when :high
|
|
48
|
+
data.except(*SENSITIVITY_CONTENT_KEYS)
|
|
49
|
+
when :medium
|
|
50
|
+
redact_sensitive_keys(data)
|
|
51
|
+
else
|
|
52
|
+
data
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.redact_sensitive_keys(data)
|
|
57
|
+
data.each_with_object({}) do |(key, value), filtered|
|
|
58
|
+
filtered[key] = if SENSITIVITY_CONTENT_KEYS.include?(key)
|
|
59
|
+
redact_value(value)
|
|
60
|
+
else
|
|
61
|
+
value
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.redact_value(value)
|
|
67
|
+
case value
|
|
68
|
+
when String
|
|
69
|
+
"[REDACTED]"
|
|
70
|
+
when Hash
|
|
71
|
+
value.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
|
|
72
|
+
else
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.filter_fields(type, data)
|
|
78
|
+
configured_fields = Smith.config.trace_fields
|
|
79
|
+
return data unless configured_fields.is_a?(Hash)
|
|
80
|
+
|
|
81
|
+
allowed = configured_fields[type]
|
|
82
|
+
return data unless allowed.respond_to?(:include?)
|
|
83
|
+
|
|
84
|
+
data.each_with_object({}) do |(key, value), filtered|
|
|
85
|
+
filtered[key] = value if allowed.include?(key)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/smith/types.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-types"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Types
|
|
7
|
+
include Dry.Types()
|
|
8
|
+
|
|
9
|
+
# Dry.Types() resolves constants via const_missing, which means
|
|
10
|
+
# const_defined?(:String, false) returns false. Contract specs use
|
|
11
|
+
# strict const_defined?(name, false) lookups, so we materialize
|
|
12
|
+
# the constants we need as direct module constants.
|
|
13
|
+
const_set(:String, const_get(:String))
|
|
14
|
+
const_set(:Integer, const_get(:Integer))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module ArtifactIntegration
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def with_scoped_artifacts
|
|
11
|
+
if @inherited_scoped_artifacts
|
|
12
|
+
Smith.scoped_artifacts = @inherited_scoped_artifacts
|
|
13
|
+
else
|
|
14
|
+
backend = Smith.config.artifact_store || Smith.artifacts
|
|
15
|
+
Smith.scoped_artifacts = Artifacts::ScopedStore.new(backend: backend, namespace: execution_namespace)
|
|
16
|
+
end
|
|
17
|
+
yield
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def execution_namespace
|
|
21
|
+
@execution_namespace ||= SecureRandom.uuid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def propagate_scoped_artifacts
|
|
25
|
+
Smith.scoped_artifacts
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def build_session
|
|
29
|
+
manager = self.class.context_manager
|
|
30
|
+
messages = @session_messages ||= []
|
|
31
|
+
return nil if manager.nil? && messages.empty?
|
|
32
|
+
|
|
33
|
+
Context::Session.new(
|
|
34
|
+
messages: messages,
|
|
35
|
+
context_manager: manager,
|
|
36
|
+
persisted_context: @context
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module BudgetIntegration
|
|
6
|
+
TOKEN_DIMENSIONS = %i[total_tokens token_limit].freeze
|
|
7
|
+
COST_DIMENSIONS = %i[total_cost].freeze
|
|
8
|
+
BUDGET_DIMENSIONS = (TOKEN_DIMENSIONS + COST_DIMENSIONS).freeze
|
|
9
|
+
AGENT_DIM_MAP = { token_limit: TOKEN_DIMENSIONS, cost: COST_DIMENSIONS }.freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def reserve_branch_budget(ledger, branch_estimates:)
|
|
14
|
+
return nil unless ledger && branch_estimates
|
|
15
|
+
|
|
16
|
+
branch_estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
|
|
17
|
+
branch_estimates
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def compute_branch_estimates(ledger, branch_count:, agent_budget: nil)
|
|
21
|
+
return nil unless ledger
|
|
22
|
+
|
|
23
|
+
ledger.limits.each_with_object({}) do |(dim, _limit), est|
|
|
24
|
+
per_branch = estimate_for_dimension(dim, ledger.remaining(dim), branch_count)
|
|
25
|
+
cap = agent_cap_for_dimension(dim, agent_budget)
|
|
26
|
+
est[dim] = cap ? [per_branch, cap].min : per_branch
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def reconcile_branch_budget(ledger, estimates, agent_result: nil)
|
|
31
|
+
return unless ledger && estimates
|
|
32
|
+
|
|
33
|
+
actuals = extract_actuals(agent_result)
|
|
34
|
+
estimates.each do |dim, amt|
|
|
35
|
+
ledger.reconcile!(dim, amt, actual_for_dimension(dim, actuals[:tokens], actuals[:cost]))
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def extract_actuals(agent_result)
|
|
40
|
+
{
|
|
41
|
+
tokens: (agent_result&.input_tokens || 0) + (agent_result&.output_tokens || 0),
|
|
42
|
+
cost: agent_result&.cost || 0
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def actual_for_dimension(dim, actual_tokens, actual_cost = 0)
|
|
47
|
+
return actual_tokens if TOKEN_DIMENSIONS.include?(dim)
|
|
48
|
+
return actual_cost if COST_DIMENSIONS.include?(dim)
|
|
49
|
+
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def release_branch_budget(ledger, estimates)
|
|
54
|
+
return unless ledger && estimates
|
|
55
|
+
|
|
56
|
+
estimates.each { |dim, amount| ledger.release!(dim, amount) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def settle_budget_on_failure(ledger, estimates, agent_result)
|
|
60
|
+
return unless ledger && estimates
|
|
61
|
+
|
|
62
|
+
if agent_result
|
|
63
|
+
reconcile_branch_budget(ledger, estimates, agent_result: agent_result)
|
|
64
|
+
else
|
|
65
|
+
release_branch_budget(ledger, estimates)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reserve_serial_budget(ledger, agent_budget: nil)
|
|
70
|
+
return nil unless ledger
|
|
71
|
+
|
|
72
|
+
estimates = ledger.limits.each_with_object({}) do |(dim, _limit), est|
|
|
73
|
+
remaining = BUDGET_DIMENSIONS.include?(dim) ? ledger.remaining(dim) : 0
|
|
74
|
+
cap = agent_cap_for_dimension(dim, agent_budget)
|
|
75
|
+
est[dim] = cap ? [remaining, cap].min : remaining
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
estimates.each { |dim, amount| ledger.reserve!(dim, amount) }
|
|
79
|
+
estimates
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def finalize_branch(transition, index, result, ledger, reserved)
|
|
83
|
+
agent_result = result.is_a?(Workflow::AgentResult) ? result : nil
|
|
84
|
+
reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
|
|
85
|
+
{ branch: index, agent: transition.agent_name, output: agent_result ? agent_result.content : result }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def estimate_for_dimension(dim, limit, branch_count)
|
|
89
|
+
return 0 unless BUDGET_DIMENSIONS.include?(dim)
|
|
90
|
+
|
|
91
|
+
TOKEN_DIMENSIONS.include?(dim) ? [limit / branch_count, 1].max : limit / branch_count
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def agent_cap_for_dimension(dim, agent_budget)
|
|
95
|
+
return nil unless agent_budget
|
|
96
|
+
return agent_budget[dim] if agent_budget.key?(dim)
|
|
97
|
+
|
|
98
|
+
AGENT_DIM_MAP.each do |agent_dim, workflow_dims|
|
|
99
|
+
return agent_budget[agent_dim] if workflow_dims.include?(dim) && agent_budget.key?(agent_dim)
|
|
100
|
+
end
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# ActiveRecord-aware atomic claim helper. Two strategies:
|
|
6
|
+
#
|
|
7
|
+
# .atomic — AASM-event path. SELECT FOR UPDATE + record.public_send(transition_via)
|
|
8
|
+
# inside transaction_owner.transaction. AASM callbacks fire.
|
|
9
|
+
# .cas — single-statement CAS via update_all + where(status: from_statuses).
|
|
10
|
+
# Does NOT invoke AASM events; intended for non-AASM claim sites
|
|
11
|
+
# that already use update_all today.
|
|
12
|
+
#
|
|
13
|
+
# ActiveRecord is loaded lazily — this file does NOT const-reference
|
|
14
|
+
# ::ActiveRecord at module load. Both methods raise AdapterUnavailable
|
|
15
|
+
# before any other work when ::ActiveRecord is not defined.
|
|
16
|
+
module Claim
|
|
17
|
+
class AdapterUnavailable < Smith::Error; end
|
|
18
|
+
class UnexpectedStatus < Smith::Error
|
|
19
|
+
attr_reader :model, :id, :observed_status
|
|
20
|
+
|
|
21
|
+
def initialize(model:, id:, observed_status:)
|
|
22
|
+
@model = model
|
|
23
|
+
@id = id
|
|
24
|
+
@observed_status = observed_status
|
|
25
|
+
super("unexpected status #{observed_status.inspect} for #{model.name}##{id}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.atomic(model_class, id:, from_statuses:, transition_via:,
|
|
30
|
+
terminal_statuses: [], on_unexpected_status: :raise,
|
|
31
|
+
transaction_owner: nil)
|
|
32
|
+
ensure_active_record!
|
|
33
|
+
validate_atomic_args!(model_class, transition_via)
|
|
34
|
+
|
|
35
|
+
owner = transaction_owner || model_class
|
|
36
|
+
from = Array(from_statuses).map(&:to_s)
|
|
37
|
+
terminal = Array(terminal_statuses).map(&:to_s)
|
|
38
|
+
|
|
39
|
+
claimed = false
|
|
40
|
+
|
|
41
|
+
owner.transaction do
|
|
42
|
+
locked = model_class.lock.find(id)
|
|
43
|
+
status = locked.public_send(:status).to_s
|
|
44
|
+
|
|
45
|
+
if from.include?(status)
|
|
46
|
+
locked.public_send(transition_via)
|
|
47
|
+
claimed = true
|
|
48
|
+
elsif terminal.include?(status)
|
|
49
|
+
claimed = false
|
|
50
|
+
else
|
|
51
|
+
handle_unexpected_status!(model_class, id, status, on_unexpected_status)
|
|
52
|
+
claimed = false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
claimed ? model_class.find(id) : nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.cas(model_class, id:, from_statuses:, to_status:,
|
|
60
|
+
status_column: :status, updated_at_column: :updated_at,
|
|
61
|
+
now: -> { Time.now.utc })
|
|
62
|
+
ensure_active_record!
|
|
63
|
+
from = Array(from_statuses).map(&:to_s)
|
|
64
|
+
|
|
65
|
+
updates = { status_column => to_status.to_s }
|
|
66
|
+
updates[updated_at_column] = now.call if updated_at_column
|
|
67
|
+
|
|
68
|
+
rows = model_class
|
|
69
|
+
.where(:id => id, status_column => from)
|
|
70
|
+
.update_all(updates)
|
|
71
|
+
|
|
72
|
+
rows.zero? ? nil : model_class.find(id)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.ensure_active_record!
|
|
76
|
+
return if defined?(::ActiveRecord::Base)
|
|
77
|
+
|
|
78
|
+
raise AdapterUnavailable,
|
|
79
|
+
"Smith::Workflow::Claim requires ActiveRecord (::ActiveRecord::Base is not defined). " \
|
|
80
|
+
"Add activerecord to your bundle. See docs/workflow_claim.md."
|
|
81
|
+
end
|
|
82
|
+
private_class_method :ensure_active_record!
|
|
83
|
+
|
|
84
|
+
def self.validate_atomic_args!(model_class, transition_via)
|
|
85
|
+
if transition_via.nil?
|
|
86
|
+
if model_class.respond_to?(:aasm)
|
|
87
|
+
raise ArgumentError,
|
|
88
|
+
"Smith::Workflow::Claim.atomic requires transition_via: when the model uses AASM " \
|
|
89
|
+
"(#{model_class.name} responds to .aasm). Use .cas for non-AASM CAS claims."
|
|
90
|
+
end
|
|
91
|
+
raise ArgumentError, "Smith::Workflow::Claim.atomic requires transition_via: (Symbol naming the event method)"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
return if transition_via.is_a?(Symbol) || transition_via.is_a?(String)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "transition_via must be a Symbol or String; got #{transition_via.inspect}"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :validate_atomic_args!
|
|
99
|
+
|
|
100
|
+
def self.handle_unexpected_status!(model_class, id, status, mode)
|
|
101
|
+
case mode
|
|
102
|
+
when :raise
|
|
103
|
+
raise UnexpectedStatus.new(model: model_class, id: id, observed_status: status)
|
|
104
|
+
when :ignore
|
|
105
|
+
nil
|
|
106
|
+
when :log
|
|
107
|
+
Smith.config.logger&.warn(
|
|
108
|
+
"Smith::Workflow::Claim.atomic: unexpected status #{status.inspect} for #{model_class.name}##{id}"
|
|
109
|
+
)
|
|
110
|
+
nil
|
|
111
|
+
else
|
|
112
|
+
raise ArgumentError, "on_unexpected_status must be :raise, :ignore, or :log; got #{mode.inspect}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
private_class_method :handle_unexpected_status!
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module DataVolumePolicy
|
|
6
|
+
LIGHTWEIGHT_SCALARS = [String, Integer, Float, Symbol, TrueClass, FalseClass, NilClass].freeze
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def validate_data_volume!(output, agent_class)
|
|
11
|
+
return unless agent_class.respond_to?(:data_volume)
|
|
12
|
+
return unless agent_class.data_volume == :unbounded
|
|
13
|
+
return unless output.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
require_ref_key!(output)
|
|
16
|
+
require_scalar_values!(output)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def require_ref_key!(output)
|
|
20
|
+
return if output.keys.any? { |k| k.to_s.end_with?("_ref") }
|
|
21
|
+
|
|
22
|
+
raise GuardrailFailed, "data_volume :unbounded requires at least one *_ref key"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def require_scalar_values!(output)
|
|
26
|
+
output.each do |key, value|
|
|
27
|
+
next if key.to_s.end_with?("_ref")
|
|
28
|
+
next if LIGHTWEIGHT_SCALARS.any? { |type| value.is_a?(type) }
|
|
29
|
+
|
|
30
|
+
raise GuardrailFailed,
|
|
31
|
+
"data_volume :unbounded requires lightweight scalar values, got #{value.class} for :#{key}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module DeadlineEnforcement
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def check_deadline!
|
|
11
|
+
deadline = effective_deadline
|
|
12
|
+
return unless deadline
|
|
13
|
+
|
|
14
|
+
raise DeadlineExceeded, "wall_clock deadline exceeded" if Time.now.utc >= deadline
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def effective_deadline
|
|
18
|
+
call_dl = Thread.current[:smith_call_deadline]
|
|
19
|
+
[wall_clock_deadline, call_dl].compact.min
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def with_agent_context(agent_class)
|
|
23
|
+
saved_deadline = Tool.current_deadline
|
|
24
|
+
saved_call_ledger = Thread.current[:smith_call_ledger]
|
|
25
|
+
apply_agent_deadline(agent_class)
|
|
26
|
+
narrow_tool_deadline!
|
|
27
|
+
apply_agent_tool_calls(agent_class)
|
|
28
|
+
apply_agent_call_ledger(agent_class)
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
Tool.current_deadline = saved_deadline
|
|
32
|
+
Thread.current[:smith_call_ledger] = saved_call_ledger
|
|
33
|
+
clear_agent_deadline
|
|
34
|
+
clear_agent_tool_calls
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def effective_call_ledger
|
|
38
|
+
@ledger || Thread.current[:smith_call_ledger]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def apply_agent_deadline(agent_class)
|
|
42
|
+
agent_wc = agent_class&.budget&.dig(:wall_clock)
|
|
43
|
+
Thread.current[:smith_call_deadline] = agent_wc ? Time.now.utc + agent_wc : nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def clear_agent_deadline
|
|
47
|
+
Thread.current[:smith_call_deadline] = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def narrow_tool_deadline!
|
|
51
|
+
call_dl = Thread.current[:smith_call_deadline]
|
|
52
|
+
return unless call_dl
|
|
53
|
+
|
|
54
|
+
current = Tool.current_deadline
|
|
55
|
+
Tool.current_deadline = current ? [current, call_dl].min : call_dl
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def apply_agent_tool_calls(agent_class)
|
|
59
|
+
agent_tc = agent_class&.budget&.dig(:tool_calls)
|
|
60
|
+
Tool.current_tool_call_allowance = agent_tc ? { remaining: agent_tc } : nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def clear_agent_tool_calls
|
|
64
|
+
Tool.current_tool_call_allowance = nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def apply_agent_call_ledger(agent_class)
|
|
68
|
+
Thread.current[:smith_call_ledger] = @ledger ? nil : build_agent_call_ledger(agent_class)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_agent_call_ledger(agent_class)
|
|
72
|
+
agent_budget = agent_class&.budget
|
|
73
|
+
return nil unless agent_budget
|
|
74
|
+
|
|
75
|
+
limits = {}
|
|
76
|
+
limits[:token_limit] = agent_budget[:token_limit] if agent_budget[:token_limit]
|
|
77
|
+
limits[:total_cost] = agent_budget[:cost] if agent_budget[:cost]
|
|
78
|
+
return nil if limits.empty?
|
|
79
|
+
|
|
80
|
+
Budget::Ledger.new(limits: limits)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def wall_clock_deadline
|
|
84
|
+
return @wall_clock_deadline if defined?(@wall_clock_deadline)
|
|
85
|
+
|
|
86
|
+
@wall_clock_deadline = compute_wall_clock_deadline
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def compute_wall_clock_deadline
|
|
90
|
+
limit = self.class.budget&.dig(:wall_clock)
|
|
91
|
+
own_deadline = limit ? Time.iso8601(@created_at) + limit : nil
|
|
92
|
+
|
|
93
|
+
return own_deadline unless @inherited_deadline
|
|
94
|
+
return @inherited_deadline unless own_deadline
|
|
95
|
+
|
|
96
|
+
[own_deadline, @inherited_deadline].min
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module DeterministicExecution
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def execute_deterministic_step(transition)
|
|
9
|
+
check_deadline!
|
|
10
|
+
step = build_deterministic_step(transition)
|
|
11
|
+
emit_deterministic_trace(transition, result: :started)
|
|
12
|
+
transition.deterministic_block.call(step)
|
|
13
|
+
apply_deterministic_writes!(step)
|
|
14
|
+
emit_deterministic_trace(
|
|
15
|
+
transition,
|
|
16
|
+
result: step.routed_to ? :routed : :success,
|
|
17
|
+
routed_to: step.routed_to,
|
|
18
|
+
outcome_kind: step.outcome&.dig(:kind)
|
|
19
|
+
)
|
|
20
|
+
nil
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
emit_deterministic_trace(transition, result: :failed, error: e.message)
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_deterministic_step(transition)
|
|
27
|
+
DeterministicStep.new(
|
|
28
|
+
context: snapshot_value(@context),
|
|
29
|
+
session_messages: snapshot_value(@session_messages || []),
|
|
30
|
+
tool_results: snapshot_value(@tool_results || []),
|
|
31
|
+
state: @state,
|
|
32
|
+
transition_name: transition.name
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def apply_deterministic_writes!(step)
|
|
37
|
+
@context.merge!(step.context_writes)
|
|
38
|
+
step.context_writes.each_key { |key| record_persisted_key!(key) }
|
|
39
|
+
@router_next_transition = step.routed_to if step.routed_to
|
|
40
|
+
@outcome = snapshot_value(step.outcome) if step.outcome
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def emit_deterministic_trace(transition, result:, routed_to: nil, error: nil, outcome_kind: nil)
|
|
44
|
+
data = { transition: transition.name, from: transition.from, to: transition.to,
|
|
45
|
+
kind: transition.deterministic_kind, result: result }
|
|
46
|
+
data[:routed_to] = routed_to if routed_to
|
|
47
|
+
data[:error] = error if error
|
|
48
|
+
data[:outcome_kind] = outcome_kind if outcome_kind
|
|
49
|
+
Smith::Trace.record(type: :deterministic_step, data: data)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class DeterministicStep
|
|
6
|
+
attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
|
|
7
|
+
:context_writes, :routed_to, :outcome
|
|
8
|
+
|
|
9
|
+
def initialize(context:, session_messages:, tool_results:, state:, transition_name:)
|
|
10
|
+
@context = context
|
|
11
|
+
@session_messages = session_messages
|
|
12
|
+
@tool_results = tool_results
|
|
13
|
+
@current_state = state
|
|
14
|
+
@transition_name = transition_name
|
|
15
|
+
@context_writes = {}
|
|
16
|
+
@routed_to = nil
|
|
17
|
+
@outcome = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def last_output
|
|
21
|
+
return @last_output if defined?(@last_output)
|
|
22
|
+
|
|
23
|
+
msg = session_messages.reverse.find { |m| m[:role] == :assistant || m[:role] == "assistant" }
|
|
24
|
+
@last_output = msg&.dig(:content)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
alias_method :output, :last_output
|
|
28
|
+
|
|
29
|
+
def read_context(key)
|
|
30
|
+
@context_writes.key?(key) ? @context_writes[key] : context[key]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def write_context(key, value)
|
|
34
|
+
raise WorkflowError, "write_context key must be a Symbol, got #{key.class}" unless key.is_a?(Symbol)
|
|
35
|
+
|
|
36
|
+
@context_writes[key] = value
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def route_to(transition_name)
|
|
40
|
+
raise WorkflowError, "route_to already called with :#{@routed_to}" if @routed_to
|
|
41
|
+
|
|
42
|
+
@routed_to = transition_name
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def write_outcome(kind:, payload:)
|
|
46
|
+
raise WorkflowError, "write_outcome kind must be a Symbol, got #{kind.class}" unless kind.is_a?(Symbol)
|
|
47
|
+
raise WorkflowError, "write_outcome already called with :#{@outcome[:kind]}" if @outcome
|
|
48
|
+
|
|
49
|
+
@outcome = { kind: kind, payload: payload }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fail!(message, retryable: nil, kind: nil, details: nil)
|
|
53
|
+
raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|