smith-agents 0.4.2 → 0.4.3
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 +55 -3
- data/README.md +45 -2
- data/UPSTREAM_PROPOSAL.md +19 -0
- data/docs/PATTERNS.md +41 -1
- data/lib/smith/agent/registry/introspection.rb +27 -0
- data/lib/smith/agent/registry.rb +22 -17
- data/lib/smith/agent/registry_binding.rb +43 -0
- data/lib/smith/agent.rb +3 -4
- data/lib/smith/doctor/checks/models_registry.rb +25 -14
- data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/agent_result.rb +26 -0
- data/lib/smith/workflow/branch_env.rb +24 -0
- data/lib/smith/workflow/budget_integration.rb +2 -0
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +32 -4
- data/lib/smith/workflow/durability.rb +38 -12
- data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
- data/lib/smith/workflow/event_integration.rb +3 -3
- data/lib/smith/workflow/execution.rb +2 -0
- data/lib/smith/workflow/fanout_execution.rb +2 -0
- data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
- data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
- data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
- data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
- data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
- data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
- data/lib/smith/workflow/graph/targets.rb +5 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
- data/lib/smith/workflow/graph.rb +16 -1
- data/lib/smith/workflow/graph_dsl.rb +4 -0
- data/lib/smith/workflow/guardrail_integration.rb +20 -3
- data/lib/smith/workflow/nested_execution.rb +5 -4
- data/lib/smith/workflow/optimization_state.rb +13 -0
- data/lib/smith/workflow/orchestration_state.rb +13 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -15
- data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
- data/lib/smith/workflow/parallel.rb +6 -15
- data/lib/smith/workflow/parallel_execution.rb +2 -0
- data/lib/smith/workflow/persistence.rb +37 -6
- data/lib/smith/workflow/router.rb +15 -4
- data/lib/smith/workflow/run_result.rb +54 -0
- data/lib/smith/workflow/transition.rb +90 -4
- data/lib/smith/workflow/usage_entry.rb +30 -0
- data/lib/smith/workflow/worker_execution.rb +13 -0
- data/lib/smith/workflow.rb +40 -136
- data/lib/smith.rb +9 -0
- metadata +21 -1
|
@@ -4,14 +4,17 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class DeterministicStep
|
|
6
6
|
attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
|
|
7
|
-
:context_writes, :routed_to, :outcome
|
|
7
|
+
:context_writes, :routed_to, :outcome, :allowed_routes
|
|
8
8
|
|
|
9
|
-
def initialize(context:, session_messages:, tool_results:, state:,
|
|
9
|
+
def initialize(context:, session_messages:, tool_results:, state:, **options)
|
|
10
|
+
validate_options!(options)
|
|
11
|
+
transition = options[:transition]
|
|
10
12
|
@context = context
|
|
11
13
|
@session_messages = session_messages
|
|
12
14
|
@tool_results = tool_results
|
|
13
15
|
@current_state = state
|
|
14
|
-
@transition_name = transition_name
|
|
16
|
+
@transition_name = transition ? transition.name : options.fetch(:transition_name)
|
|
17
|
+
@allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
|
|
15
18
|
@context_writes = {}
|
|
16
19
|
@routed_to = nil
|
|
17
20
|
@outcome = nil
|
|
@@ -39,7 +42,7 @@ module Smith
|
|
|
39
42
|
def route_to(transition_name)
|
|
40
43
|
raise WorkflowError, "route_to already called with :#{@routed_to}" if @routed_to
|
|
41
44
|
|
|
42
|
-
@routed_to = transition_name
|
|
45
|
+
@routed_to = route_target_for(transition_name)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def write_outcome(kind:, payload:)
|
|
@@ -52,6 +55,31 @@ module Smith
|
|
|
52
55
|
def fail!(message, retryable: nil, kind: nil, details: nil)
|
|
53
56
|
raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
|
|
54
57
|
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_options!(options)
|
|
62
|
+
unknown = options.keys - %i[transition transition_name allowed_routes]
|
|
63
|
+
raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
|
|
64
|
+
return if options[:transition] || options.key?(:transition_name)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "missing keyword: :transition"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def snapshot_allowed_routes(routes)
|
|
70
|
+
return nil if routes.nil?
|
|
71
|
+
|
|
72
|
+
routes.map { |route| route.is_a?(String) ? route.dup.freeze : route }.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def route_target_for(transition_name)
|
|
76
|
+
return transition_name if allowed_routes.nil?
|
|
77
|
+
|
|
78
|
+
allowed_route = allowed_routes.find { |route| route == transition_name }
|
|
79
|
+
return allowed_route if allowed_route
|
|
80
|
+
|
|
81
|
+
raise WorkflowError, "route_to #{transition_name.inspect} is not declared in deterministic routes"
|
|
82
|
+
end
|
|
55
83
|
end
|
|
56
84
|
end
|
|
57
85
|
end
|
|
@@ -32,11 +32,9 @@ module Smith
|
|
|
32
32
|
# `store/fetch/delete`, and this peek piggybacks on `fetch`.
|
|
33
33
|
# Custom adapters work without changes.
|
|
34
34
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# in-flight workflow is being resumed — either way, no NEW
|
|
39
|
-
# credit authorization is needed).
|
|
35
|
+
# Hosts can use this to distinguish a resumed workflow from a
|
|
36
|
+
# brand-new workflow when coordinating external accounting or
|
|
37
|
+
# admission-control checks.
|
|
40
38
|
def persisted_state_exists?(key: nil, context: {}, adapter: Smith.persistence_adapter)
|
|
41
39
|
resolved_key = resolved_persistence_key(key:, context:)
|
|
42
40
|
!fetch_persisted_payload(resolved_key, adapter:).nil?
|
|
@@ -51,11 +49,9 @@ module Smith
|
|
|
51
49
|
# the top of `run_persisted!` BEFORE the first `advance!`. A
|
|
52
50
|
# worker that dies between that initial `persist!` and the
|
|
53
51
|
# first model call leaves a Redis key with no billable work.
|
|
54
|
-
# If
|
|
55
|
-
# alone, a
|
|
56
|
-
# state
|
|
57
|
-
# skipped because state exists, but the state has nothing to
|
|
58
|
-
# bill — it's just the workflow's starting state).
|
|
52
|
+
# If external accounting or admission-control checks key on
|
|
53
|
+
# `persisted_state_exists?` alone, a retry on that abandoned
|
|
54
|
+
# init state can be mistaken for a billable/resumable workflow.
|
|
59
55
|
#
|
|
60
56
|
# `restorable_billing_state?` returns true only when there's
|
|
61
57
|
# actual `usage_entries` to bill on idempotent replay. Terminal
|
|
@@ -167,11 +163,22 @@ module Smith
|
|
|
167
163
|
return false unless state_name && class_name
|
|
168
164
|
|
|
169
165
|
klass = Object.const_get(class_name)
|
|
170
|
-
klass
|
|
166
|
+
state = state_name_for_payload(klass, state_name)
|
|
167
|
+
klass.transitions_from(state).empty? && next_transition.nil?
|
|
171
168
|
rescue JSON::ParserError, NameError, NoMethodError
|
|
172
169
|
false
|
|
173
170
|
end
|
|
174
171
|
|
|
172
|
+
def state_name_for_payload(klass, state_name)
|
|
173
|
+
states = klass.instance_variable_get(:@states) || []
|
|
174
|
+
return state_name if states.include?(state_name)
|
|
175
|
+
|
|
176
|
+
symbolized = state_name.to_sym if state_name.respond_to?(:to_sym)
|
|
177
|
+
return symbolized if states.include?(symbolized)
|
|
178
|
+
|
|
179
|
+
state_name
|
|
180
|
+
end
|
|
181
|
+
|
|
175
182
|
def persistence_adapter!(adapter)
|
|
176
183
|
return adapter if adapter
|
|
177
184
|
|
|
@@ -241,12 +248,19 @@ module Smith
|
|
|
241
248
|
persist!(resolved_key, adapter:)
|
|
242
249
|
|
|
243
250
|
until terminal?
|
|
251
|
+
ensure_transition_budget!
|
|
252
|
+
|
|
244
253
|
if strict_idempotency?
|
|
245
254
|
mark_step_in_progress!
|
|
246
255
|
persist!(resolved_key, adapter:)
|
|
247
256
|
end
|
|
248
257
|
|
|
249
|
-
|
|
258
|
+
begin
|
|
259
|
+
step = advance!
|
|
260
|
+
rescue StandardError
|
|
261
|
+
clear_pre_step_marker!(resolved_key, adapter:)
|
|
262
|
+
raise
|
|
263
|
+
end
|
|
250
264
|
steps << step if step
|
|
251
265
|
|
|
252
266
|
clear_step_in_progress!
|
|
@@ -261,6 +275,7 @@ module Smith
|
|
|
261
275
|
return if terminal?
|
|
262
276
|
|
|
263
277
|
resolved_key = resolve_persistence_key!(key)
|
|
278
|
+
ensure_transition_budget!
|
|
264
279
|
mark_step_in_progress! if strict_idempotency?
|
|
265
280
|
persist!(resolved_key, adapter:)
|
|
266
281
|
step = advance!
|
|
@@ -268,6 +283,9 @@ module Smith
|
|
|
268
283
|
persist!(resolved_key, adapter:) if step
|
|
269
284
|
invoke_on_step_callback(step, on_step) if step
|
|
270
285
|
step
|
|
286
|
+
rescue StandardError
|
|
287
|
+
clear_pre_step_marker!(resolved_key, adapter:) if defined?(resolved_key) && resolved_key
|
|
288
|
+
raise
|
|
271
289
|
end
|
|
272
290
|
|
|
273
291
|
def persist!(key = nil, adapter: Smith.persistence_adapter)
|
|
@@ -306,6 +324,14 @@ module Smith
|
|
|
306
324
|
self.class.idempotency_mode == :strict
|
|
307
325
|
end
|
|
308
326
|
|
|
327
|
+
def clear_pre_step_marker!(key, adapter:)
|
|
328
|
+
return unless strict_idempotency?
|
|
329
|
+
return if step_work_started?
|
|
330
|
+
|
|
331
|
+
clear_step_in_progress!
|
|
332
|
+
persist!(key, adapter:)
|
|
333
|
+
end
|
|
334
|
+
|
|
309
335
|
# Forwards the persist payload to the adapter, splatting `ttl:`
|
|
310
336
|
# only when a TTL is resolved. The empty-Hash splat is a no-op so
|
|
311
337
|
# external duck-typed adapters that don't accept a `ttl:` kwarg
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "agent_result"
|
|
6
|
+
require_relative "optimization_state"
|
|
7
|
+
|
|
3
8
|
module Smith
|
|
4
9
|
class Workflow
|
|
5
10
|
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
11
|
private
|
|
15
12
|
|
|
16
13
|
def execute_optimization_step(transition, prepared_input: nil)
|
|
@@ -13,9 +13,9 @@ module Smith
|
|
|
13
13
|
|
|
14
14
|
Smith::Events.emit(
|
|
15
15
|
Events::StepCompleted.new(
|
|
16
|
-
transition: transition.name,
|
|
17
|
-
from: transition.from,
|
|
18
|
-
to: transition.to
|
|
16
|
+
transition: transition.name.to_sym,
|
|
17
|
+
from: transition.from&.to_sym,
|
|
18
|
+
to: transition.to.to_sym
|
|
19
19
|
)
|
|
20
20
|
)
|
|
21
21
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Graph
|
|
6
|
+
module ContractHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def label_for(value)
|
|
10
|
+
label = if value.respond_to?(:name) && value.name && !value.name.empty?
|
|
11
|
+
value.name
|
|
12
|
+
elsif value.respond_to?(:inspect)
|
|
13
|
+
value.inspect
|
|
14
|
+
else
|
|
15
|
+
value.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
immutable_value(label)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def idempotency_mode
|
|
22
|
+
return :unknown unless workflow_class.respond_to?(:idempotency_mode)
|
|
23
|
+
|
|
24
|
+
workflow_class.idempotency_mode
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def in_flight_resume
|
|
28
|
+
return :blocked_by_step_in_progress if idempotency_mode == :strict
|
|
29
|
+
return :unknown unless %i[strict lax].include?(idempotency_mode)
|
|
30
|
+
|
|
31
|
+
:reruns_transition
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def immutable_value(value)
|
|
35
|
+
return value if immediate_value?(value)
|
|
36
|
+
|
|
37
|
+
duplicate = value.dup
|
|
38
|
+
return value if duplicate.equal?(value)
|
|
39
|
+
|
|
40
|
+
duplicate.freeze
|
|
41
|
+
rescue TypeError
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def immediate_value?(value)
|
|
46
|
+
value.nil? || value.is_a?(Symbol) || value.is_a?(Numeric) ||
|
|
47
|
+
value == true || value == false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deep_freeze(value)
|
|
51
|
+
case value
|
|
52
|
+
when Hash
|
|
53
|
+
value.each_value { |nested| deep_freeze(nested) }
|
|
54
|
+
value.freeze
|
|
55
|
+
when Array
|
|
56
|
+
value.each { |nested| deep_freeze(nested) }
|
|
57
|
+
value.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Graph
|
|
6
|
+
class FanoutContract
|
|
7
|
+
attr_reader :transition, :workflow_class
|
|
8
|
+
|
|
9
|
+
def self.from_transition(transition, workflow_class: nil)
|
|
10
|
+
branches = transition.fanout_config&.fetch(:branches, nil)
|
|
11
|
+
return unless branches
|
|
12
|
+
|
|
13
|
+
new(transition, workflow_class: workflow_class).to_h
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(transition, workflow_class: nil)
|
|
17
|
+
@transition = transition
|
|
18
|
+
@workflow_class = workflow_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
deep_freeze(
|
|
23
|
+
branch_count: branches.length,
|
|
24
|
+
join_state: immutable_value(transition.to),
|
|
25
|
+
output_shape: :named_branch_results,
|
|
26
|
+
branch_order: :declaration_order,
|
|
27
|
+
join: join_contract,
|
|
28
|
+
output_contract: output_contract,
|
|
29
|
+
resume_contract: resume_contract,
|
|
30
|
+
branches: branch_summaries,
|
|
31
|
+
branch_contracts: branch_contracts
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def branches
|
|
38
|
+
transition.fanout_config.fetch(:branches)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def join_contract
|
|
42
|
+
{
|
|
43
|
+
state: immutable_value(transition.to),
|
|
44
|
+
transition: immutable_value(transition.name)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def output_contract
|
|
49
|
+
{
|
|
50
|
+
collection: :array,
|
|
51
|
+
item_shape: :named_branch_result,
|
|
52
|
+
ordering: :branch_declaration_order,
|
|
53
|
+
branch_key_field: :branch,
|
|
54
|
+
agent_field: :agent,
|
|
55
|
+
output_field: :output,
|
|
56
|
+
failure: :discard_all_branch_results_on_failure
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resume_contract
|
|
61
|
+
{
|
|
62
|
+
granularity: :transition,
|
|
63
|
+
branch_checkpointing: false,
|
|
64
|
+
idempotency_mode: idempotency_mode,
|
|
65
|
+
in_flight_resume: in_flight_resume
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def branch_summaries
|
|
70
|
+
branches.map do |branch, agent|
|
|
71
|
+
{ branch: immutable_value(branch), agent: immutable_value(agent) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def branch_contracts
|
|
76
|
+
branches.map do |branch, agent|
|
|
77
|
+
branch_value = immutable_value(branch)
|
|
78
|
+
agent_value = immutable_value(agent)
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
branch: branch_value,
|
|
82
|
+
agent: agent_value,
|
|
83
|
+
result_branch_value: branch_value,
|
|
84
|
+
result_shape: {
|
|
85
|
+
branch: branch_value,
|
|
86
|
+
agent: agent_value,
|
|
87
|
+
output: :agent_output
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def immutable_value(value)
|
|
94
|
+
return value if immediate_value?(value)
|
|
95
|
+
|
|
96
|
+
duplicate = value.dup
|
|
97
|
+
return value if duplicate.equal?(value)
|
|
98
|
+
|
|
99
|
+
duplicate.freeze
|
|
100
|
+
rescue TypeError
|
|
101
|
+
# Some host-owned topology values intentionally refuse duplication.
|
|
102
|
+
# Leave them untouched; the graph contract containers are still
|
|
103
|
+
# frozen, and inspection must never mutate workflow-owned values.
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def immediate_value?(value)
|
|
108
|
+
value.nil? || value.is_a?(Symbol) || value.is_a?(Numeric) ||
|
|
109
|
+
value == true || value == false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def idempotency_mode
|
|
113
|
+
return :unknown unless workflow_class&.respond_to?(:idempotency_mode)
|
|
114
|
+
|
|
115
|
+
workflow_class.idempotency_mode
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def in_flight_resume
|
|
119
|
+
return :blocked_by_step_in_progress if idempotency_mode == :strict
|
|
120
|
+
return :unknown unless %i[strict lax].include?(idempotency_mode)
|
|
121
|
+
|
|
122
|
+
:reruns_transition
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def deep_freeze(value)
|
|
126
|
+
case value
|
|
127
|
+
when Hash
|
|
128
|
+
value.each_value { |nested| deep_freeze(nested) }
|
|
129
|
+
value.freeze
|
|
130
|
+
when Array
|
|
131
|
+
value.each { |nested| deep_freeze(nested) }
|
|
132
|
+
value.freeze
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class Graph
|
|
8
|
+
class NestedReadinessDiagnostics
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
param :graph
|
|
12
|
+
option :visited, default: proc {}
|
|
13
|
+
|
|
14
|
+
def to_a
|
|
15
|
+
cycle_diagnostics + report_entries.flat_map { |entry| diagnostics_for(entry) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def child_reports
|
|
19
|
+
report_entries.map { |entry| entry.fetch(:report) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def diagnostics_for(entry)
|
|
25
|
+
entry.fetch(:report).diagnostics.map do |diagnostic|
|
|
26
|
+
nested_diagnostic(entry.fetch(:transition), entry.fetch(:workflow_class), diagnostic)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def cycle_diagnostics
|
|
31
|
+
nested_workflow_transitions.filter_map do |transition|
|
|
32
|
+
workflow_class = transition.workflow_class
|
|
33
|
+
cycle_diagnostic(transition, workflow_class) if visited_workflows.include?(workflow_class)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def report_entries
|
|
38
|
+
@report_entries ||= nested_workflow_transitions.filter_map do |transition|
|
|
39
|
+
workflow_class = transition.workflow_class
|
|
40
|
+
next if visited_workflows.include?(workflow_class)
|
|
41
|
+
|
|
42
|
+
{
|
|
43
|
+
transition: transition,
|
|
44
|
+
workflow_class: workflow_class,
|
|
45
|
+
report: workflow_class.graph.runtime_readiness(visited: visited_workflows)
|
|
46
|
+
}.freeze
|
|
47
|
+
end.freeze
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def nested_workflow_transitions
|
|
51
|
+
@nested_workflow_transitions ||= graph.transitions.values.select(&:nested?)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def cycle_diagnostic(transition, workflow_class)
|
|
55
|
+
label = workflow_label(workflow_class)
|
|
56
|
+
|
|
57
|
+
Diagnostic.new(
|
|
58
|
+
severity: :error,
|
|
59
|
+
code: :nested_workflow_cycle,
|
|
60
|
+
transition: transition.name,
|
|
61
|
+
target: label,
|
|
62
|
+
message: "Transition #{ref(transition.name)} nests #{label}, " \
|
|
63
|
+
"which is already in the readiness inspection stack.",
|
|
64
|
+
suggestion: "Break the nested workflow cycle before runtime execution."
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def nested_diagnostic(transition, workflow_class, diagnostic)
|
|
69
|
+
label = workflow_label(workflow_class)
|
|
70
|
+
|
|
71
|
+
Diagnostic.new(
|
|
72
|
+
severity: diagnostic.severity,
|
|
73
|
+
code: :"nested_#{diagnostic.code}",
|
|
74
|
+
transition: transition.name,
|
|
75
|
+
target: label,
|
|
76
|
+
message: "Nested workflow #{label}: #{diagnostic.message}",
|
|
77
|
+
suggestion: diagnostic.suggestion
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def visited_workflows
|
|
82
|
+
@visited_workflows ||= Set.new(Array(visited)).add(graph.workflow_class)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def ref(value)
|
|
86
|
+
Reference.format(value)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def workflow_label(workflow_class)
|
|
90
|
+
name = workflow_class.name
|
|
91
|
+
return name if name && !name.empty?
|
|
92
|
+
|
|
93
|
+
workflow_class.inspect
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
require_relative "contract_helpers"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Workflow
|
|
9
|
+
class Graph
|
|
10
|
+
class OptimizationContract
|
|
11
|
+
extend Dry::Initializer
|
|
12
|
+
include ContractHelpers
|
|
13
|
+
|
|
14
|
+
param :transition
|
|
15
|
+
option :workflow_class, default: proc {}
|
|
16
|
+
|
|
17
|
+
def self.from_transition(transition, workflow_class: nil)
|
|
18
|
+
new(transition, workflow_class: workflow_class).to_h if transition.optimization_config
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
deep_freeze(
|
|
23
|
+
transition_contract.merge(
|
|
24
|
+
exit_modes: exit_modes,
|
|
25
|
+
output_contract: output_contract,
|
|
26
|
+
resume_contract: resume_contract
|
|
27
|
+
).compact
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def config
|
|
34
|
+
transition.optimization_config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def transition_contract
|
|
38
|
+
{
|
|
39
|
+
generator: immutable_value(config.fetch(:generator)),
|
|
40
|
+
evaluator: immutable_value(config.fetch(:evaluator)),
|
|
41
|
+
max_rounds: config.fetch(:max_rounds)
|
|
42
|
+
}.merge(evaluation_contract)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def evaluation_contract
|
|
46
|
+
{
|
|
47
|
+
evaluator_schema: label_for(config.fetch(:evaluator_schema)),
|
|
48
|
+
evaluator_context: config[:evaluator_context],
|
|
49
|
+
improvement_threshold: config[:improvement_threshold],
|
|
50
|
+
before_eval: callable_label(config[:before_eval])
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def exit_modes
|
|
55
|
+
{
|
|
56
|
+
exhaustion: exit_mode_label(config.fetch(:on_exhaustion)),
|
|
57
|
+
converged: exit_mode_label(config.fetch(:on_converged)),
|
|
58
|
+
threshold: exit_mode_label(config.fetch(:on_threshold))
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def output_contract
|
|
63
|
+
{
|
|
64
|
+
success: :accepted_or_configured_exit_candidate,
|
|
65
|
+
failure: :workflow_error_on_malformed_or_unaccepted_evaluation,
|
|
66
|
+
evaluator_output: {
|
|
67
|
+
required: { accept: :boolean },
|
|
68
|
+
rejection_requires: %i[feedback],
|
|
69
|
+
threshold_requires: %i[score],
|
|
70
|
+
optional: %i[score converged]
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resume_contract
|
|
76
|
+
{
|
|
77
|
+
granularity: :transition,
|
|
78
|
+
round_checkpointing: false,
|
|
79
|
+
idempotency_mode: idempotency_mode,
|
|
80
|
+
in_flight_resume: in_flight_resume
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def exit_mode_label(value)
|
|
85
|
+
return value if value.is_a?(Symbol)
|
|
86
|
+
|
|
87
|
+
callable_label(value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def callable_label(value)
|
|
91
|
+
value.respond_to?(:call) ? :callable : nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|