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
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
module OrchestratorWorker
|
|
9
|
+
OrchestrationState = Struct.new(
|
|
10
|
+
:config, :prepared_input, :orchestrator_class, :worker_class, :worker_results
|
|
11
|
+
) do
|
|
12
|
+
def initialize(config, prepared_input)
|
|
13
|
+
super(config, prepared_input, nil, nil, nil)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
WorkerExecution = Struct.new(:execution_id, :task, :output) do
|
|
18
|
+
def self.run(worker_class, task, schema, budget_runner)
|
|
19
|
+
new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def dispatch_step(transition, prepared_input: nil)
|
|
26
|
+
if transition.parallel? then execute_parallel_step(transition, prepared_input: prepared_input)
|
|
27
|
+
elsif transition.nested? then execute_nested_workflow(transition)
|
|
28
|
+
elsif transition.optimized? then execute_optimization_step(transition, prepared_input: prepared_input)
|
|
29
|
+
elsif transition.orchestrated? then execute_orchestration_step(transition, prepared_input: prepared_input)
|
|
30
|
+
elsif transition.deterministic? then execute_deterministic_step(transition)
|
|
31
|
+
else execute_serial_step(transition, prepared_input: prepared_input)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def execute_orchestration_step(transition, prepared_input: nil)
|
|
36
|
+
state = OrchestrationState.new(transition.orchestrator_config, prepared_input)
|
|
37
|
+
state.orchestrator_class = Agent::Registry.fetch!(
|
|
38
|
+
state.config[:orchestrator],
|
|
39
|
+
workflow_class: self.class,
|
|
40
|
+
transition_name: transition.name,
|
|
41
|
+
role: :orchestrator
|
|
42
|
+
)
|
|
43
|
+
state.worker_class = Agent::Registry.fetch!(
|
|
44
|
+
state.config[:worker],
|
|
45
|
+
workflow_class: self.class,
|
|
46
|
+
transition_name: transition.name,
|
|
47
|
+
role: :worker
|
|
48
|
+
)
|
|
49
|
+
run_orchestration_loop(state)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def run_orchestration_loop(state)
|
|
53
|
+
state.config[:max_delegation_rounds].times do |round|
|
|
54
|
+
result = run_orchestration_round(state, round)
|
|
55
|
+
return result if result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
raise WorkflowError,
|
|
59
|
+
"orchestration exhausted #{state.config[:max_delegation_rounds]} rounds without final output"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def run_orchestration_round(state, round)
|
|
63
|
+
decision = call_orchestrator(state, round)
|
|
64
|
+
validate_orchestrator_decision!(decision)
|
|
65
|
+
|
|
66
|
+
return validated_final(decision[:final], state.config) if decision.key?(:final)
|
|
67
|
+
raise WorkflowError, "orchestrator stopped: #{decision[:stop]}" if decision.key?(:stop)
|
|
68
|
+
|
|
69
|
+
validate_tasks!(decision[:tasks], state.config)
|
|
70
|
+
state.worker_results = execute_workers(state, decision[:tasks])
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def call_orchestrator(state, round)
|
|
75
|
+
input = prepare_orchestrator_input(state.prepared_input, round, state.worker_results)
|
|
76
|
+
invoke_agent_with_budget(state.orchestrator_class, input)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def execute_workers(state, tasks)
|
|
80
|
+
runner = method(:run_worker_with_schema)
|
|
81
|
+
tasks.map do |task|
|
|
82
|
+
validate_task!(task, state.config[:task_schema])
|
|
83
|
+
execution = WorkerExecution.run(state.worker_class, task, state.config[:worker_output_schema], runner)
|
|
84
|
+
validate_worker_output!(execution.output, state.config[:worker_output_schema])
|
|
85
|
+
{ execution_id: execution.execution_id, task: execution.task, output: execution.output }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run_worker_with_schema(worker_class, task, worker_output_schema)
|
|
90
|
+
input = [{ role: :user, content: task.to_json }]
|
|
91
|
+
original_schema = worker_class.output_schema
|
|
92
|
+
worker_class.output_schema(worker_output_schema)
|
|
93
|
+
invoke_agent_with_budget(worker_class, input)
|
|
94
|
+
ensure
|
|
95
|
+
worker_class.output_schema(original_schema)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def prepare_orchestrator_input(prepared_input, round, worker_results)
|
|
99
|
+
return prepared_input if round.zero?
|
|
100
|
+
|
|
101
|
+
(prepared_input&.dup || []).push(
|
|
102
|
+
{
|
|
103
|
+
role: :user,
|
|
104
|
+
content: "[smith:orchestration-round] #{round + 1}\n[smith:worker-results]\n#{worker_results.to_json}"
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_orchestrator_decision!(output)
|
|
110
|
+
raise WorkflowError, "orchestrator output must be a Hash" unless output.is_a?(Hash)
|
|
111
|
+
return if %i[tasks final stop].one? { |k| output.key?(k) }
|
|
112
|
+
|
|
113
|
+
raise WorkflowError, "orchestrator must emit exactly one of :tasks, :final, or :stop"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_tasks!(tasks, config)
|
|
117
|
+
raise WorkflowError, "orchestrator :tasks must be an Array" unless tasks.is_a?(Array)
|
|
118
|
+
return unless tasks.length > config[:max_workers]
|
|
119
|
+
|
|
120
|
+
raise WorkflowError, "orchestrator tasks (#{tasks.length}) exceeds max_workers (#{config[:max_workers]})"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def validate_task!(task, schema)
|
|
124
|
+
check_schema_keys!(task, schema, "worker task")
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_worker_output!(output, schema)
|
|
128
|
+
check_schema_keys!(output, schema, "worker output")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def validated_final(final, config)
|
|
132
|
+
check_schema_keys!(final, config[:final_output_schema], "final output")
|
|
133
|
+
final
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def check_schema_keys!(data, schema, label)
|
|
137
|
+
raise WorkflowError, "#{label} must be a Hash" unless data.is_a?(Hash)
|
|
138
|
+
return unless schema.respond_to?(:required_keys)
|
|
139
|
+
|
|
140
|
+
missing = schema.required_keys.reject { |k| data.key?(k) }
|
|
141
|
+
raise WorkflowError, "#{label} missing required keys: #{missing.join(", ")}" unless missing.empty?
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Parallel
|
|
8
|
+
CancellationSignal = Struct.new(:cancelled, :mutex) do
|
|
9
|
+
def initialize
|
|
10
|
+
super(false, Mutex.new)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cancel!
|
|
14
|
+
mutex.synchronize { self.cancelled = true }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def cancelled?
|
|
18
|
+
mutex.synchronize { cancelled }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.resolve_branch_count(transition, context)
|
|
23
|
+
count = transition.agent_opts[:count]
|
|
24
|
+
count.respond_to?(:call) ? count.call(context) : (count || 1)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.execute(branches:)
|
|
28
|
+
signal = CancellationSignal.new
|
|
29
|
+
|
|
30
|
+
futures = branches.map do |branch|
|
|
31
|
+
Concurrent::Promises.future(branch, signal) do |b, s|
|
|
32
|
+
b.call(s)
|
|
33
|
+
rescue StandardError
|
|
34
|
+
s.cancel!
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
fulfilled, values, reasons = Concurrent::Promises.zip(*futures).result
|
|
40
|
+
|
|
41
|
+
unless fulfilled
|
|
42
|
+
error = reasons.compact.first
|
|
43
|
+
raise error
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
values
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module ParallelExecution
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def execute_parallel_step(transition, prepared_input: nil)
|
|
9
|
+
count = Parallel.resolve_branch_count(transition, @context)
|
|
10
|
+
agent_class = resolve_agent_class(transition)
|
|
11
|
+
estimates = compute_branch_estimates(@ledger, branch_count: count, agent_budget: agent_class&.budget)
|
|
12
|
+
env = BranchEnv.new(
|
|
13
|
+
prepared_input: prepared_input,
|
|
14
|
+
guardrail_sources: Tool.current_guardrails,
|
|
15
|
+
scoped_store: propagate_scoped_artifacts,
|
|
16
|
+
branch_estimates: estimates,
|
|
17
|
+
deadline: wall_clock_deadline
|
|
18
|
+
)
|
|
19
|
+
ledger = @ledger
|
|
20
|
+
branches = Array.new(count) do |i|
|
|
21
|
+
proc { |signal| run_branch(transition, i, env, ledger, signal) }
|
|
22
|
+
end
|
|
23
|
+
Parallel.execute(branches: branches)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run_branch(transition, index, env, ledger, signal)
|
|
27
|
+
setup_branch_context(env, ledger)
|
|
28
|
+
with_agent_context(resolve_agent_class(transition)) do
|
|
29
|
+
branch_ledger = effective_call_ledger
|
|
30
|
+
reserved = reserve_branch_call(branch_ledger, env, ledger)
|
|
31
|
+
begin
|
|
32
|
+
result = guarded_branch_call(transition, env, signal)
|
|
33
|
+
finalize_branch(transition, index, result, branch_ledger, reserved).tap { reserved = nil }
|
|
34
|
+
ensure
|
|
35
|
+
settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
ensure
|
|
39
|
+
teardown_branch_context(env)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def reserve_branch_call(branch_ledger, env, workflow_ledger)
|
|
43
|
+
return reserve_branch_budget(branch_ledger, branch_estimates: env.branch_estimates) if workflow_ledger
|
|
44
|
+
|
|
45
|
+
reserve_serial_budget(branch_ledger) if branch_ledger
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def setup_branch_context(env, ledger)
|
|
49
|
+
env.setup_thread
|
|
50
|
+
Tool.current_ledger = ledger
|
|
51
|
+
Tool.current_tool_result_collector = tool_result_collector
|
|
52
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def teardown_branch_context(env)
|
|
56
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
57
|
+
Tool.current_ledger = nil
|
|
58
|
+
Tool.current_tool_result_collector = nil
|
|
59
|
+
env.teardown_thread
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def guarded_branch_call(transition, env, signal)
|
|
63
|
+
check_cancellation!(signal)
|
|
64
|
+
check_deadline!
|
|
65
|
+
result = execute_transition_body(transition, prepared_input: env.prepared_input)
|
|
66
|
+
check_cancellation!(signal)
|
|
67
|
+
result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_cancellation!(signal)
|
|
71
|
+
raise Smith::WorkflowError, "cancelled" if signal.cancelled?
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module Persistence
|
|
6
|
+
def to_state
|
|
7
|
+
{
|
|
8
|
+
class: self.class.name,
|
|
9
|
+
state: @state,
|
|
10
|
+
persistence_key: @persistence_key,
|
|
11
|
+
context: persisted_context,
|
|
12
|
+
budget_consumed: ledger_consumed,
|
|
13
|
+
step_count: @step_count,
|
|
14
|
+
execution_namespace: @execution_namespace,
|
|
15
|
+
created_at: @created_at,
|
|
16
|
+
updated_at: @updated_at,
|
|
17
|
+
next_transition_name: @next_transition_name,
|
|
18
|
+
session_messages: @session_messages || [],
|
|
19
|
+
total_cost: @total_cost || 0.0,
|
|
20
|
+
total_tokens: @total_tokens || 0,
|
|
21
|
+
tool_results: @tool_results || [],
|
|
22
|
+
outcome: snapshot_outcome,
|
|
23
|
+
# New durable fields for hadithi billing. All wrapped in
|
|
24
|
+
# snapshot_value so non-JSON-safe runtime values (e.g.
|
|
25
|
+
# custom Hash details on DeterministicStepFailure) get the
|
|
26
|
+
# same deep-copy treatment as context/session_messages/etc.
|
|
27
|
+
usage_entries: snapshot_value((@usage_entries || []).map(&:to_h)),
|
|
28
|
+
last_output: snapshot_value(@last_output),
|
|
29
|
+
last_failed_step: snapshot_value(@last_failed_step),
|
|
30
|
+
# Optimistic-locking version. Adapters that support
|
|
31
|
+
# store_versioned use this to detect concurrent writes; adapters
|
|
32
|
+
# that don't (CacheStore, RailsCache) ignore it.
|
|
33
|
+
persistence_version: @persistence_version || 0,
|
|
34
|
+
# Schema version of the workflow class that wrote this payload.
|
|
35
|
+
# Restore dispatches through migrate_from blocks when the
|
|
36
|
+
# stored value lags the workflow's current
|
|
37
|
+
# persistence_schema_version.
|
|
38
|
+
schema_version: self.class.persistence_schema_version,
|
|
39
|
+
# SHA256 digest of the seed_messages produced at this
|
|
40
|
+
# workflow's construction. Stays stable across persist/restore
|
|
41
|
+
# cycles so seed_validation can detect when the seed builder
|
|
42
|
+
# has changed in code since this workflow was persisted.
|
|
43
|
+
seed_digest: @seed_digest,
|
|
44
|
+
# Step-in-progress idempotency marker. Set true between
|
|
45
|
+
# persist-before-advance and persist-after-advance when the
|
|
46
|
+
# workflow class opts into idempotency_mode :strict. Restore
|
|
47
|
+
# raises Smith::StepInProgressOnRestore if true under strict
|
|
48
|
+
# mode. Lax mode leaves this false and never raises.
|
|
49
|
+
step_in_progress: @step_in_progress || false,
|
|
50
|
+
# Keys recorded via DeterministicStep#write_context. Used by
|
|
51
|
+
# persist :auto Context mode to scope the persisted context
|
|
52
|
+
# slice. Always emitted (sorted for stable diffing) so
|
|
53
|
+
# explicit-mode workflows produce forward-compatible payloads.
|
|
54
|
+
persisted_keys: (@persisted_keys || ::Set.new).to_a.map(&:to_sym).sort
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def restore_state(hash)
|
|
61
|
+
migrated = migrate_if_needed(hash)
|
|
62
|
+
normalized = normalize_persisted_state(migrated)
|
|
63
|
+
restore_persisted_keys(normalized)
|
|
64
|
+
restore_core_fields(normalized)
|
|
65
|
+
@persistence_key = normalized[:persistence_key]
|
|
66
|
+
@ledger = rebuild_ledger(normalized[:budget_consumed] || {})
|
|
67
|
+
@next_transition_name = normalized[:next_transition_name]
|
|
68
|
+
@session_messages = normalized[:session_messages] || []
|
|
69
|
+
@total_cost = normalized[:total_cost] || 0.0
|
|
70
|
+
@total_tokens = normalized[:total_tokens] || 0
|
|
71
|
+
@outcome = normalized[:outcome]
|
|
72
|
+
initialize_tool_result_state
|
|
73
|
+
@tool_results = normalized[:tool_results] || []
|
|
74
|
+
# Mirror the eager inits from `Workflow#initialize`. `from_state`
|
|
75
|
+
# uses `allocate` and bypasses `initialize`, so any restored
|
|
76
|
+
# workflow that later records usage would `nil.synchronize` or
|
|
77
|
+
# `nil.map` without these. Backward-compat: pre-patch states
|
|
78
|
+
# have no `usage_entries`/`last_output`/`last_failed_step` keys
|
|
79
|
+
# and restore to the empty defaults.
|
|
80
|
+
@usage_mutex = Mutex.new
|
|
81
|
+
@usage_entries = restore_usage_entries(normalized)
|
|
82
|
+
@last_output = restore_last_output(normalized)
|
|
83
|
+
@last_failed_step = restore_last_failed_step(normalized)
|
|
84
|
+
# Restore the optimistic-locking version from the persisted payload.
|
|
85
|
+
# Backward-compat: pre-versioning payloads have no key, restore to 0
|
|
86
|
+
# so the first persist! after restore expects version 0 (matches
|
|
87
|
+
# the original store from the legacy adapter contract).
|
|
88
|
+
@persistence_version = normalized[:persistence_version] || 0
|
|
89
|
+
# Preserve the seed digest from the persisted payload so it
|
|
90
|
+
# round-trips on subsequent persists. validate_seed_digest!
|
|
91
|
+
# compares this against a fresh evaluation of the seed builder
|
|
92
|
+
# only when the workflow class opts into validation.
|
|
93
|
+
@seed_digest = normalized[:seed_digest]
|
|
94
|
+
validate_seed_digest!(normalized) if self.class.seed_validation != :off
|
|
95
|
+
# Restore the step-in-progress marker so a subsequent persist
|
|
96
|
+
# round-trips it. validate_step_in_progress! enforces strict
|
|
97
|
+
# mode by raising if the marker is set on restore.
|
|
98
|
+
@step_in_progress = normalized[:step_in_progress] || false
|
|
99
|
+
validate_step_in_progress!(normalized) if self.class.idempotency_mode == :strict
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_step_in_progress!(normalized)
|
|
103
|
+
return unless normalized[:step_in_progress] == true
|
|
104
|
+
|
|
105
|
+
raise Smith::StepInProgressOnRestore.new(
|
|
106
|
+
workflow: self.class.name,
|
|
107
|
+
persistence_key: normalized[:persistence_key]
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_seed_digest!(normalized)
|
|
112
|
+
stored_digest = normalized[:seed_digest]
|
|
113
|
+
return if stored_digest.nil?
|
|
114
|
+
|
|
115
|
+
current_messages = compute_seed_messages
|
|
116
|
+
current_digest = compute_seed_digest(current_messages)
|
|
117
|
+
return if current_digest == stored_digest
|
|
118
|
+
|
|
119
|
+
case self.class.seed_validation
|
|
120
|
+
when :strict
|
|
121
|
+
raise Smith::SeedMismatch.new(
|
|
122
|
+
workflow: self.class.name,
|
|
123
|
+
stored_digest: stored_digest,
|
|
124
|
+
current_digest: current_digest
|
|
125
|
+
)
|
|
126
|
+
when :warn
|
|
127
|
+
Smith.config.logger&.warn(
|
|
128
|
+
"Smith::Workflow seed_messages drift for #{self.class.name}: " \
|
|
129
|
+
"stored digest #{stored_digest.inspect}, current digest #{current_digest.inspect}"
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def restore_usage_entries(normalized)
|
|
135
|
+
raw = normalized[:usage_entries]
|
|
136
|
+
return [] if raw.nil? || !raw.is_a?(Array)
|
|
137
|
+
|
|
138
|
+
raw.map { |h| Workflow::UsageEntry.from_h(h) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Use key-presence checks (NOT `||`) so a deliberately persisted
|
|
142
|
+
# `false` step output round-trips correctly. Smith's existing
|
|
143
|
+
# `RunResult#output` derivation uses `compact.first`, which only
|
|
144
|
+
# drops `nil` — `false` is a valid non-nil output.
|
|
145
|
+
def restore_last_output(normalized)
|
|
146
|
+
if normalized.key?(:last_output)
|
|
147
|
+
normalized[:last_output]
|
|
148
|
+
elsif normalized.key?("last_output")
|
|
149
|
+
normalized["last_output"]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Symbolize ONLY the top-level keys of last_failed_step + the
|
|
154
|
+
# known value-symbols (`transition`, `from`, `to`, `error_kind`).
|
|
155
|
+
# `error_family` stays a String (the family_fallback compares
|
|
156
|
+
# against String literals). `error_details` is left exactly as
|
|
157
|
+
# JSON.parse returned it — documented as JSON-normalized
|
|
158
|
+
# semantics on round-trip (Hash keys become strings, symbol
|
|
159
|
+
# values become strings).
|
|
160
|
+
def restore_last_failed_step(normalized)
|
|
161
|
+
raw = normalized[:last_failed_step]
|
|
162
|
+
return nil unless raw.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
165
|
+
{
|
|
166
|
+
transition: h[:transition]&.to_sym,
|
|
167
|
+
from: h[:from]&.to_sym,
|
|
168
|
+
to: h[:to]&.to_sym,
|
|
169
|
+
error_class: h[:error_class],
|
|
170
|
+
error_family: h[:error_family],
|
|
171
|
+
error_message: h[:error_message],
|
|
172
|
+
error_retryable: h[:error_retryable],
|
|
173
|
+
error_kind: h[:error_kind]&.to_sym,
|
|
174
|
+
error_details: h[:error_details]
|
|
175
|
+
}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def restore_core_fields(normalized)
|
|
179
|
+
@state = normalized[:state]
|
|
180
|
+
@context = filter_persisted_context(normalized[:context] || {})
|
|
181
|
+
@step_count = normalized[:step_count] || 0
|
|
182
|
+
@execution_namespace = normalized[:execution_namespace]
|
|
183
|
+
@created_at = normalized[:created_at]
|
|
184
|
+
@updated_at = normalized[:updated_at]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def restore_persisted_keys(normalized)
|
|
188
|
+
@persisted_keys_mutex = Mutex.new
|
|
189
|
+
raw = normalized[:persisted_keys]
|
|
190
|
+
if raw.is_a?(Array) && !raw.empty?
|
|
191
|
+
@persisted_keys = ::Set.new(raw.map(&:to_sym))
|
|
192
|
+
return
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
manager = self.class.context_manager
|
|
196
|
+
if manager && manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
197
|
+
ctx = normalized[:context]
|
|
198
|
+
existing = ctx.is_a?(Hash) ? ctx.keys.map { |k| k.to_sym } : []
|
|
199
|
+
seed = manager.persist_auto_seed.map(&:to_sym)
|
|
200
|
+
@persisted_keys = ::Set.new(existing + seed)
|
|
201
|
+
else
|
|
202
|
+
@persisted_keys = ::Set.new
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Bridges stored schema_version to the workflow class's current
|
|
207
|
+
# persistence_schema_version by walking registered migrate_from
|
|
208
|
+
# blocks one step at a time. Returns the (possibly migrated)
|
|
209
|
+
# payload with symbol top-level keys, ready for the rest of the
|
|
210
|
+
# normalize pipeline. Pre-versioning payloads are treated as v1
|
|
211
|
+
# for backward compatibility with state written before Smith
|
|
212
|
+
# carried :schema_version.
|
|
213
|
+
def migrate_if_needed(hash)
|
|
214
|
+
payload = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
215
|
+
current = self.class.persistence_schema_version
|
|
216
|
+
stored = payload[:schema_version] || 1
|
|
217
|
+
|
|
218
|
+
return payload if stored == current
|
|
219
|
+
|
|
220
|
+
if stored > current
|
|
221
|
+
raise Smith::PersistenceSchemaMismatch.new(
|
|
222
|
+
workflow: self.class.name, stored: stored, current: current
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
cursor = stored
|
|
227
|
+
while cursor < current
|
|
228
|
+
migration = self.class.migrations[cursor]
|
|
229
|
+
unless migration
|
|
230
|
+
raise Smith::PersistenceSchemaMismatch.new(
|
|
231
|
+
workflow: self.class.name, stored: cursor, current: current
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
payload = migration.call(payload)
|
|
236
|
+
payload = payload.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
237
|
+
cursor += 1
|
|
238
|
+
# Defensive: advance :schema_version if the migration block
|
|
239
|
+
# forgot to set it, so the loop terminates.
|
|
240
|
+
payload[:schema_version] = cursor if (payload[:schema_version] || 0) < cursor
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
payload
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def normalize_persisted_state(hash)
|
|
247
|
+
normalized = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
248
|
+
normalize_symbol_fields!(normalized)
|
|
249
|
+
normalize_nested_hashes!(normalized)
|
|
250
|
+
normalize_session_messages!(normalized)
|
|
251
|
+
normalize_tool_results!(normalized)
|
|
252
|
+
normalize_usage_entries!(normalized)
|
|
253
|
+
normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
|
|
254
|
+
normalized
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def normalize_symbol_fields!(normalized)
|
|
258
|
+
normalized[:state] = normalized[:state]&.to_sym
|
|
259
|
+
if normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?(:kind)
|
|
260
|
+
normalized[:outcome][:kind] = normalized[:outcome][:kind]&.to_sym
|
|
261
|
+
elsif normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?("kind")
|
|
262
|
+
normalized[:outcome]["kind"] = normalized[:outcome]["kind"]&.to_sym
|
|
263
|
+
end
|
|
264
|
+
return unless normalized.key?(:next_transition_name)
|
|
265
|
+
|
|
266
|
+
normalized[:next_transition_name] = normalized[:next_transition_name]&.to_sym
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def normalize_nested_hashes!(normalized)
|
|
270
|
+
normalized[:context] = symbolize_keys(normalized[:context]) if normalized[:context].is_a?(Hash)
|
|
271
|
+
return unless normalized[:budget_consumed].is_a?(Hash)
|
|
272
|
+
|
|
273
|
+
normalized[:budget_consumed] = symbolize_keys(normalized[:budget_consumed])
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def normalize_session_messages!(normalized)
|
|
277
|
+
return unless normalized[:session_messages].is_a?(Array)
|
|
278
|
+
|
|
279
|
+
normalized[:session_messages] = normalized[:session_messages].map do |msg|
|
|
280
|
+
msg.is_a?(Hash) ? symbolize_keys(msg) : msg
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def normalize_tool_results!(normalized)
|
|
285
|
+
return unless normalized[:tool_results].is_a?(Array)
|
|
286
|
+
|
|
287
|
+
normalized[:tool_results] = normalized[:tool_results].map do |entry|
|
|
288
|
+
entry.is_a?(Hash) ? symbolize_keys(entry) : entry
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def normalize_usage_entries!(normalized)
|
|
293
|
+
return unless normalized[:usage_entries].is_a?(Array)
|
|
294
|
+
|
|
295
|
+
normalized[:usage_entries] = normalized[:usage_entries].map do |entry|
|
|
296
|
+
entry.is_a?(Hash) ? symbolize_keys(entry) : entry
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def symbolize_keys(hash)
|
|
301
|
+
hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def symbolize_value(value)
|
|
305
|
+
case value
|
|
306
|
+
when Hash
|
|
307
|
+
value.each_with_object({}) do |(key, nested), copy|
|
|
308
|
+
normalized_key = key.is_a?(String) ? key.to_sym : key
|
|
309
|
+
copy[normalized_key] = symbolize_value(nested)
|
|
310
|
+
end
|
|
311
|
+
when Array
|
|
312
|
+
value.map { |nested| symbolize_value(nested) }
|
|
313
|
+
else
|
|
314
|
+
value
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def ledger_consumed
|
|
319
|
+
@ledger ? @ledger.consumed.to_h : {}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def rebuild_ledger(consumed)
|
|
323
|
+
config = self.class.budget
|
|
324
|
+
return nil unless config
|
|
325
|
+
|
|
326
|
+
Budget::Ledger.new(limits: config, consumed: consumed)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def persisted_context
|
|
330
|
+
keys = resolve_persist_keys
|
|
331
|
+
return @context if keys.nil?
|
|
332
|
+
return @context.slice(*(@persisted_keys || ::Set.new).to_a) if keys == :auto
|
|
333
|
+
|
|
334
|
+
@context.slice(*keys)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def filter_persisted_context(context)
|
|
338
|
+
keys = resolve_persist_keys
|
|
339
|
+
return context if keys.nil?
|
|
340
|
+
return context.slice(*(@persisted_keys || ::Set.new).to_a) if keys == :auto
|
|
341
|
+
|
|
342
|
+
context.slice(*keys)
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def resolve_persist_keys
|
|
346
|
+
manager = self.class.context_manager
|
|
347
|
+
return nil unless manager
|
|
348
|
+
|
|
349
|
+
if manager.respond_to?(:persist_mode) && manager.persist_mode == :auto
|
|
350
|
+
return :auto
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
keys = manager.persist
|
|
354
|
+
keys.empty? ? nil : keys
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|