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,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module EvaluatorOptimizer
|
|
6
|
+
OptimizationState = Struct.new(
|
|
7
|
+
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
|
|
8
|
+
) do
|
|
9
|
+
def initialize(config, prepared_input)
|
|
10
|
+
super(config, prepared_input, nil, nil, nil, nil, nil)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def execute_optimization_step(transition, prepared_input: nil)
|
|
17
|
+
state = OptimizationState.new(transition.optimization_config, prepared_input)
|
|
18
|
+
state.generator_class = Agent::Registry.fetch!(
|
|
19
|
+
state.config[:generator],
|
|
20
|
+
workflow_class: self.class,
|
|
21
|
+
transition_name: transition.name,
|
|
22
|
+
role: :generator
|
|
23
|
+
)
|
|
24
|
+
state.evaluator_class = Agent::Registry.fetch!(
|
|
25
|
+
state.config[:evaluator],
|
|
26
|
+
workflow_class: self.class,
|
|
27
|
+
transition_name: transition.name,
|
|
28
|
+
role: :evaluator
|
|
29
|
+
)
|
|
30
|
+
run_optimization_loop(state)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_optimization_loop(state)
|
|
34
|
+
state.config[:max_rounds].times do |round|
|
|
35
|
+
result = run_optimization_round(state, round)
|
|
36
|
+
return result if result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
handle_exit(state, :on_exhaustion,
|
|
40
|
+
"optimization exhausted #{state.config[:max_rounds]} rounds without acceptance")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def run_optimization_round(state, round)
|
|
44
|
+
generate_candidate!(state, round)
|
|
45
|
+
invoke_before_eval(state)
|
|
46
|
+
evaluation = normalize_evaluation(evaluate_candidate(state))
|
|
47
|
+
validate_evaluation_structure!(evaluation)
|
|
48
|
+
validate_evaluation_fields!(evaluation, state.config)
|
|
49
|
+
|
|
50
|
+
return state.candidate if evaluation[:accept]
|
|
51
|
+
|
|
52
|
+
if evaluation[:converged]
|
|
53
|
+
return handle_exit(state, :on_converged,
|
|
54
|
+
"optimization converged without acceptance after round #{round + 1}")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
threshold_exit = check_improvement_threshold!(evaluation, state, round)
|
|
58
|
+
return threshold_exit if threshold_exit
|
|
59
|
+
|
|
60
|
+
state.last_score = evaluation[:score]
|
|
61
|
+
state.feedback = evaluation[:feedback]
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# :raise => WorkflowError(message); :return_last => state.candidate;
|
|
66
|
+
# callable => mode.call(state). Default :raise preserves legacy
|
|
67
|
+
# behavior for hosts that don't opt in to graceful exits.
|
|
68
|
+
def handle_exit(state, mode_key, message)
|
|
69
|
+
mode = state.config[mode_key]
|
|
70
|
+
case mode
|
|
71
|
+
when :raise then raise WorkflowError, message
|
|
72
|
+
when :return_last then state.candidate
|
|
73
|
+
else
|
|
74
|
+
mode.call(state)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Real RubyLLM schema-bound responses come back as Hash with String
|
|
79
|
+
# keys. The validate_evaluation_* helpers below expect symbol keys
|
|
80
|
+
# (test stubs use symbol keys, masking the gap). String input also
|
|
81
|
+
# arrives when an evaluator stubs raw JSON. Normalize to a uniform
|
|
82
|
+
# symbol-keyed Hash so the validators stay clean and the rest of
|
|
83
|
+
# the loop can use `evaluation[:accept]` semantics without caring
|
|
84
|
+
# which provider returned the payload.
|
|
85
|
+
#
|
|
86
|
+
# Pure-Ruby deep-symbolize: Smith doesn't depend on ActiveSupport,
|
|
87
|
+
# so `deep_symbolize_keys` isn't available. The recursion mirrors
|
|
88
|
+
# what `Hash#transform_keys` plus a nested-Hash walk would do.
|
|
89
|
+
def normalize_evaluation(evaluation)
|
|
90
|
+
case evaluation
|
|
91
|
+
when Hash
|
|
92
|
+
deep_symbolize_evaluation(evaluation)
|
|
93
|
+
when String
|
|
94
|
+
parsed = (JSON.parse(evaluation, symbolize_names: true) rescue nil)
|
|
95
|
+
parsed.is_a?(Hash) ? parsed : evaluation
|
|
96
|
+
else
|
|
97
|
+
evaluation
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def deep_symbolize_evaluation(value)
|
|
102
|
+
case value
|
|
103
|
+
when Hash
|
|
104
|
+
value.each_with_object({}) do |(key, nested), out|
|
|
105
|
+
sym_key = key.is_a?(String) ? key.to_sym : key
|
|
106
|
+
out[sym_key] = deep_symbolize_evaluation(nested)
|
|
107
|
+
end
|
|
108
|
+
when Array
|
|
109
|
+
value.map { |item| deep_symbolize_evaluation(item) }
|
|
110
|
+
else
|
|
111
|
+
value
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def generate_candidate!(state, round)
|
|
116
|
+
input = prepare_generator_input(state.prepared_input, round, state.candidate, state.feedback)
|
|
117
|
+
result = invoke_agent_with_budget(state.generator_class, input)
|
|
118
|
+
state.candidate = result
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def evaluate_candidate(state)
|
|
122
|
+
input = build_evaluator_input(state)
|
|
123
|
+
invoke_with_evaluator_schema(state.evaluator_class, state.config[:evaluator_schema], input)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# evaluator_context: :inject_state appends the candidate as a
|
|
127
|
+
# user turn to the prepared_input the generator received, so the
|
|
128
|
+
# evaluator sees the same seed_messages + inject_state context.
|
|
129
|
+
# Default nil keeps the legacy candidate-only payload.
|
|
130
|
+
def build_evaluator_input(state)
|
|
131
|
+
return [{ role: :user, content: state.candidate.to_s }] unless state.config[:evaluator_context] == :inject_state
|
|
132
|
+
|
|
133
|
+
prior = Array(state.prepared_input).dup
|
|
134
|
+
prior.push(role: :user, content: state.candidate.to_s)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Runs after candidate generation, before evaluator invocation.
|
|
138
|
+
# Receives (state, @context); @context is mutable. Return value
|
|
139
|
+
# is discarded. Raised exceptions bubble through the standard
|
|
140
|
+
# step failure path.
|
|
141
|
+
def invoke_before_eval(state)
|
|
142
|
+
callback = state.config[:before_eval]
|
|
143
|
+
return unless callback
|
|
144
|
+
|
|
145
|
+
callback.call(state, @context)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def invoke_with_evaluator_schema(evaluator_class, schema, input)
|
|
149
|
+
original_schema = evaluator_class.output_schema
|
|
150
|
+
evaluator_class.output_schema(schema)
|
|
151
|
+
invoke_agent_with_budget(evaluator_class, input)
|
|
152
|
+
ensure
|
|
153
|
+
evaluator_class.output_schema(original_schema)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def invoke_agent_with_budget(agent_class, prepared_input)
|
|
157
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
158
|
+
with_agent_context(agent_class) do
|
|
159
|
+
invoke_with_call_ledger(agent_class, prepared_input)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def invoke_with_call_ledger(agent_class, prepared_input)
|
|
164
|
+
ledger = effective_call_ledger
|
|
165
|
+
reserved = reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
|
|
166
|
+
begin
|
|
167
|
+
result = invoke_agent(agent_class, prepared_input)
|
|
168
|
+
agent_result = result.is_a?(AgentResult) ? result : nil
|
|
169
|
+
reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
|
|
170
|
+
reserved = nil
|
|
171
|
+
agent_result ? agent_result.content : result
|
|
172
|
+
ensure
|
|
173
|
+
settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
|
|
174
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Returns nil when the threshold doesn't trip. When it does,
|
|
179
|
+
# routes through on_threshold and returns the resulting value
|
|
180
|
+
# (non-nil terminates the loop with that as the step output).
|
|
181
|
+
def check_improvement_threshold!(evaluation, state, round)
|
|
182
|
+
return nil unless stop_for_threshold?(evaluation[:score], state.last_score, state.config[:improvement_threshold])
|
|
183
|
+
|
|
184
|
+
handle_exit(state, :on_threshold,
|
|
185
|
+
"optimization improvement below threshold after round #{round + 1}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def prepare_generator_input(prepared_input, round, prior_candidate, feedback)
|
|
189
|
+
return prepared_input if round.zero?
|
|
190
|
+
|
|
191
|
+
(prepared_input&.dup || []).push(
|
|
192
|
+
{ role: :assistant, content: prior_candidate.to_s },
|
|
193
|
+
{
|
|
194
|
+
role: :user,
|
|
195
|
+
content: "[smith:refinement-round] #{round + 1}\n[smith:evaluator-feedback]\n#{feedback}"
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def validate_evaluation_structure!(evaluation)
|
|
201
|
+
raise WorkflowError, "evaluator output must be a Hash" unless evaluation.is_a?(Hash)
|
|
202
|
+
raise WorkflowError, "evaluator output missing :accept" unless evaluation.key?(:accept)
|
|
203
|
+
raise WorkflowError, "evaluator :accept must be boolean" unless [true, false].include?(evaluation[:accept])
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def validate_evaluation_fields!(evaluation, config)
|
|
207
|
+
unless evaluation[:accept] || evaluation[:feedback]
|
|
208
|
+
raise WorkflowError, "evaluator must provide :feedback when not accepted"
|
|
209
|
+
end
|
|
210
|
+
return unless config[:improvement_threshold] && !evaluation[:score].is_a?(Numeric)
|
|
211
|
+
|
|
212
|
+
raise WorkflowError, "evaluator must provide numeric :score when improvement_threshold is configured"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def stop_for_threshold?(current_score, last_score, threshold)
|
|
216
|
+
threshold && last_score && current_score.is_a?(Numeric) && (current_score - last_score).abs < threshold
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module EventIntegration
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def emit_step_completed(transition, _output)
|
|
9
|
+
Smith::Trace.record(
|
|
10
|
+
type: :transition,
|
|
11
|
+
data: { transition: transition.name, from: transition.from, to: transition.to }
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
Smith::Events.emit(
|
|
15
|
+
Events::StepCompleted.new(
|
|
16
|
+
transition: transition.name,
|
|
17
|
+
from: transition.from,
|
|
18
|
+
to: transition.to
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module Execution
|
|
6
|
+
include Agent::Lifecycle
|
|
7
|
+
include NestedExecution
|
|
8
|
+
include EvaluatorOptimizer
|
|
9
|
+
include OrchestratorWorker
|
|
10
|
+
include ParallelExecution
|
|
11
|
+
include DeterministicExecution
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def execute_step(transition)
|
|
16
|
+
setup_step_context
|
|
17
|
+
output = with_scoped_artifacts { run_guarded_step(transition) }
|
|
18
|
+
complete_step(transition, output)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
@outcome = nil
|
|
21
|
+
handle_step_failure(transition, e)
|
|
22
|
+
{ transition: transition.name, from: transition.from, to: transition.to, error: e }
|
|
23
|
+
ensure
|
|
24
|
+
teardown_step_context
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def setup_step_context
|
|
28
|
+
Tool.current_deadline = wall_clock_deadline
|
|
29
|
+
Tool.current_ledger = @ledger
|
|
30
|
+
Tool.current_tool_result_collector = tool_result_collector
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def teardown_step_context
|
|
34
|
+
Tool.current_guardrails = nil
|
|
35
|
+
Tool.current_deadline = nil
|
|
36
|
+
Tool.current_ledger = nil
|
|
37
|
+
Tool.current_tool_result_collector = nil
|
|
38
|
+
Smith.scoped_artifacts = nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run_guarded_step(transition)
|
|
42
|
+
return dispatch_step(transition) if transition.deterministic?
|
|
43
|
+
|
|
44
|
+
agent_class = resolve_agent_class(transition)
|
|
45
|
+
run_input_guardrails(agent_class)
|
|
46
|
+
apply_tool_guardrails(agent_class)
|
|
47
|
+
prepared_input = build_session&.prepare!
|
|
48
|
+
|
|
49
|
+
output = with_agent_context(agent_class) do
|
|
50
|
+
dispatch_step(transition, prepared_input: prepared_input)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
validate_data_volume!(output, agent_class)
|
|
54
|
+
run_output_guardrails(output, agent_class)
|
|
55
|
+
resolve_router_output(transition, output)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def complete_step(transition, output)
|
|
59
|
+
@state = transition.to
|
|
60
|
+
@next_transition_name = @router_next_transition || transition.success_transition
|
|
61
|
+
@router_next_transition = nil
|
|
62
|
+
append_accepted_output(output)
|
|
63
|
+
emit_step_completed(transition, output)
|
|
64
|
+
{ transition: transition.name, from: transition.from, to: transition.to, output: output }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def append_accepted_output(output)
|
|
68
|
+
return unless @session_messages
|
|
69
|
+
return if output.nil?
|
|
70
|
+
|
|
71
|
+
@session_messages << { role: :assistant, content: output }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve_router_output(transition, output)
|
|
75
|
+
return output unless transition.routed?
|
|
76
|
+
|
|
77
|
+
@router_next_transition = Router.resolve(output, transition.router_config, workflow_class: self.class)
|
|
78
|
+
nil # routed steps have no user-facing output
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def execute_transition_body(transition, prepared_input: nil)
|
|
82
|
+
@last_prepared_input = prepared_input
|
|
83
|
+
return nil unless transition.agent_name
|
|
84
|
+
|
|
85
|
+
agent_class = resolve_agent_class(transition)
|
|
86
|
+
# Accepts either static `model "id"` (chat_kwargs[:model]) OR
|
|
87
|
+
# block-form `model { |ctx| ... }` (model_block). The block-form
|
|
88
|
+
# path resolves the actual id in build_model_chain at attempt time.
|
|
89
|
+
return nil unless agent_class.model_configured?
|
|
90
|
+
|
|
91
|
+
invoke_agent(agent_class, prepared_input)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def execute_serial_step(transition, prepared_input: nil)
|
|
95
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
96
|
+
ledger = effective_call_ledger
|
|
97
|
+
reserved = reserve_for_serial(transition, ledger)
|
|
98
|
+
begin
|
|
99
|
+
result = execute_transition_body(transition, prepared_input: prepared_input)
|
|
100
|
+
agent_result = result.is_a?(AgentResult) ? result : nil
|
|
101
|
+
reconcile_branch_budget(ledger, reserved, agent_result: agent_result)
|
|
102
|
+
reserved = nil
|
|
103
|
+
agent_result ? agent_result.content : result
|
|
104
|
+
ensure
|
|
105
|
+
settle_budget_on_failure(ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
|
|
106
|
+
Thread.current[:smith_last_agent_result] = nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def reserve_for_serial(transition, ledger)
|
|
111
|
+
agent_class = resolve_agent_class(transition)
|
|
112
|
+
reserve_serial_budget(ledger, agent_budget: agent_class&.budget)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def resolve_agent_class(transition)
|
|
116
|
+
return nil unless transition.agent_name
|
|
117
|
+
|
|
118
|
+
Agent::Registry.fetch!(
|
|
119
|
+
transition.agent_name,
|
|
120
|
+
workflow_class: self.class,
|
|
121
|
+
transition_name: transition.name,
|
|
122
|
+
role: :agent
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
# Absorbs the five-flag bookkeeping pattern (claimed, result_obtained,
|
|
8
|
+
# recorded, intentional_retry, finalize_succeeded) duplicated across
|
|
9
|
+
# host Execution wrappers. The host yields its per-attempt work in,
|
|
10
|
+
# records lifecycle milestones via mark_*! setters, and the frame's
|
|
11
|
+
# ensure invokes on_clear / always_ensure based on the canonical
|
|
12
|
+
# decision: claimed && (finalize_succeeded || (intentional_retry &&
|
|
13
|
+
# recorded) || !result_obtained).
|
|
14
|
+
#
|
|
15
|
+
# OrderingError and AlreadyRun inherit from Smith::Error (NOT
|
|
16
|
+
# Smith::WorkflowError) so host `rescue Smith::WorkflowError` blocks
|
|
17
|
+
# cannot silently downgrade ordering bugs to handler-error states.
|
|
18
|
+
class ExecutionFrame
|
|
19
|
+
class OrderingError < Smith::Error; end
|
|
20
|
+
class AlreadyRun < Smith::Error; end
|
|
21
|
+
|
|
22
|
+
def self.run(workflow: nil, on_clear: nil, always_ensure: nil, logger: nil)
|
|
23
|
+
new(workflow: workflow, on_clear: on_clear, always_ensure: always_ensure, logger: logger).run { |frame| yield frame }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(workflow: nil, on_clear: nil, always_ensure: nil, logger: nil)
|
|
27
|
+
@workflow = workflow
|
|
28
|
+
@on_clear = on_clear
|
|
29
|
+
@always_ensure = always_ensure
|
|
30
|
+
@logger = logger
|
|
31
|
+
@claimed = false
|
|
32
|
+
@claimed_set = false
|
|
33
|
+
@result_obtained = false
|
|
34
|
+
@recorded = false
|
|
35
|
+
@intentional_retry = false
|
|
36
|
+
@finalize_succeeded = false
|
|
37
|
+
@run_invoked = false
|
|
38
|
+
@finished = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
raise AlreadyRun, "ExecutionFrame already run" if @run_invoked
|
|
43
|
+
|
|
44
|
+
@run_invoked = true
|
|
45
|
+
result = yield(self)
|
|
46
|
+
result
|
|
47
|
+
ensure
|
|
48
|
+
finish!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mark_claimed!(value = true)
|
|
52
|
+
if @claimed_set && @claimed != value
|
|
53
|
+
raise OrderingError, "mark_claimed! called twice with conflicting values (#{@claimed.inspect} then #{value.inspect})"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
@claimed = value
|
|
57
|
+
@claimed_set = true
|
|
58
|
+
value
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def mark_result_obtained!
|
|
62
|
+
raise OrderingError, "mark_result_obtained! requires prior mark_claimed!(true)" unless @claimed == true
|
|
63
|
+
|
|
64
|
+
@result_obtained = true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def mark_recorded!
|
|
68
|
+
raise OrderingError, "mark_recorded! requires prior mark_result_obtained!" unless @result_obtained
|
|
69
|
+
|
|
70
|
+
@recorded = true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def mark_intentional_retry!(value = true)
|
|
74
|
+
if @claimed_set && @claimed == false
|
|
75
|
+
raise OrderingError, "mark_intentional_retry! invalid after mark_claimed!(false)"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@intentional_retry = value
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mark_finalize_succeeded!
|
|
82
|
+
raise OrderingError, "mark_finalize_succeeded! requires prior mark_recorded!" unless @recorded
|
|
83
|
+
|
|
84
|
+
@finalize_succeeded = true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def claimed?
|
|
88
|
+
@claimed == true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def result_obtained?
|
|
92
|
+
@result_obtained
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def recorded?
|
|
96
|
+
@recorded
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def intentional_retry?
|
|
100
|
+
@intentional_retry
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def finalize_succeeded?
|
|
104
|
+
@finalize_succeeded
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def should_clear?
|
|
108
|
+
claimed? && (@finalize_succeeded || (@intentional_retry && @recorded) || !@result_obtained)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def finish!
|
|
112
|
+
return false if @finished
|
|
113
|
+
|
|
114
|
+
@finished = true
|
|
115
|
+
cleared = false
|
|
116
|
+
|
|
117
|
+
if should_clear?
|
|
118
|
+
cleared = invoke_on_clear
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
invoke_always_ensure if claimed?
|
|
122
|
+
|
|
123
|
+
cleared
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def invoke_on_clear
|
|
129
|
+
if @on_clear.respond_to?(:call)
|
|
130
|
+
@on_clear.call
|
|
131
|
+
return true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
target = resolve_workflow
|
|
135
|
+
if target.nil?
|
|
136
|
+
resolved_logger.warn("Smith::Workflow::ExecutionFrame: workflow resolver returned nil; skipping clear")
|
|
137
|
+
return false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
target.clear_persisted!
|
|
141
|
+
true
|
|
142
|
+
rescue StandardError => e
|
|
143
|
+
resolved_logger.error("Smith::Workflow::ExecutionFrame on_clear raised: #{e.class}: #{e.message}")
|
|
144
|
+
true
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def invoke_always_ensure
|
|
148
|
+
return unless @always_ensure.respond_to?(:call)
|
|
149
|
+
|
|
150
|
+
@always_ensure.call
|
|
151
|
+
rescue StandardError => e
|
|
152
|
+
resolved_logger.error("Smith::Workflow::ExecutionFrame always_ensure raised: #{e.class}: #{e.message}")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def resolve_workflow
|
|
156
|
+
return @workflow.call if @workflow.respond_to?(:call)
|
|
157
|
+
|
|
158
|
+
@workflow
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def resolved_logger
|
|
162
|
+
@logger || Smith.config.logger || (@_fallback_logger ||= Logger.new($stderr))
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module GuardrailIntegration
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def apply_tool_guardrails(agent_class)
|
|
9
|
+
sources = [self.class.guardrails, agent_class&.guardrails].compact
|
|
10
|
+
Tool.current_guardrails = sources.empty? ? nil : sources
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run_input_guardrails(agent_class)
|
|
14
|
+
wf_guardrails = self.class.guardrails
|
|
15
|
+
Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
|
|
16
|
+
|
|
17
|
+
agent_guardrails = agent_class&.guardrails
|
|
18
|
+
Guardrails::Runner.run_inputs(agent_guardrails, @context) if agent_guardrails
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run_output_guardrails(output, agent_class)
|
|
22
|
+
wf_guardrails = self.class.guardrails
|
|
23
|
+
Guardrails::Runner.run_outputs(wf_guardrails, output) if wf_guardrails
|
|
24
|
+
|
|
25
|
+
agent_guardrails = agent_class&.guardrails
|
|
26
|
+
Guardrails::Runner.run_outputs(agent_guardrails, output) if agent_guardrails
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def handle_step_failure(transition, _error)
|
|
30
|
+
failure_name = transition.failure_transition
|
|
31
|
+
return unless failure_name
|
|
32
|
+
|
|
33
|
+
fail_transition = self.class.find_transition(failure_name)
|
|
34
|
+
return unless fail_transition
|
|
35
|
+
|
|
36
|
+
@state = fail_transition.to
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module NestedExecution
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def execute_nested_workflow(transition)
|
|
9
|
+
check_deadline!
|
|
10
|
+
child = build_child_workflow(transition.workflow_class)
|
|
11
|
+
child_result = run_child_workflow(child)
|
|
12
|
+
handle_child_result(child_result)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def run_child_workflow(child)
|
|
16
|
+
child.run!
|
|
17
|
+
rescue Smith::Error => e
|
|
18
|
+
raise WorkflowError, "nested workflow failed: #{e.message}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_child_workflow(child_class)
|
|
22
|
+
child = child_class.new(context: @context.dup, ledger: @ledger, created_at: @created_at)
|
|
23
|
+
child.instance_variable_set(:@execution_namespace, @execution_namespace)
|
|
24
|
+
child.instance_variable_set(:@inherited_deadline, wall_clock_deadline)
|
|
25
|
+
child.instance_variable_set(:@inherited_scoped_artifacts, Smith.scoped_artifacts)
|
|
26
|
+
child
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Roll up child totals AND usage_entries BEFORE the failed-step
|
|
30
|
+
# check raises. Previously the rollup only fired on child success
|
|
31
|
+
# — billable agent work inside a failed child was silently
|
|
32
|
+
# dropped from the parent's totals/entries. The drift guard at
|
|
33
|
+
# the hadithi boundary wouldn't catch it (parent rollups + entries
|
|
34
|
+
# consistently undercount the same way; sum invariant still holds
|
|
35
|
+
# incorrectly). Roll up first, then re-raise, so the parent's
|
|
36
|
+
# terminal state reflects the child's billable work even when the
|
|
37
|
+
# child failed.
|
|
38
|
+
def handle_child_result(child_result)
|
|
39
|
+
roll_up_child_totals(child_result)
|
|
40
|
+
|
|
41
|
+
failed_step = child_result.steps.find { |s| s.key?(:error) }
|
|
42
|
+
raise WorkflowError, "nested workflow failed: #{failed_step[:error]&.message}" if failed_step
|
|
43
|
+
|
|
44
|
+
child_result.output
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `@usage_mutex` is eagerly initialized in `Workflow#initialize`
|
|
48
|
+
# AND `Workflow#restore_state`, so it's always present. Single
|
|
49
|
+
# synchronize block updates totals + entries together, matching
|
|
50
|
+
# the lifecycle.rb `record_usage` pattern.
|
|
51
|
+
#
|
|
52
|
+
# Defensive deep-copy via `from_h(snapshot_value(entry.to_h))`:
|
|
53
|
+
# `Struct#dup` is shallow (shares mutable string fields like
|
|
54
|
+
# `usage_id`/`model`), and aliasing child entries into multiple
|
|
55
|
+
# parents could let later mutations corrupt earlier parents.
|
|
56
|
+
def roll_up_child_totals(child_result)
|
|
57
|
+
child_entries = (child_result.usage_entries || []).map do |entry|
|
|
58
|
+
Workflow::UsageEntry.from_h(snapshot_value(entry.to_h))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
@usage_mutex.synchronize do
|
|
62
|
+
@total_cost = (@total_cost || 0.0) + (child_result.total_cost || 0.0)
|
|
63
|
+
@total_tokens = (@total_tokens || 0) + (child_result.total_tokens || 0)
|
|
64
|
+
@usage_entries.concat(child_entries)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|