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
data/lib/smith/workflow/graph.rb
CHANGED
|
@@ -16,8 +16,14 @@ module Smith
|
|
|
16
16
|
Validator.new(self).report
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
+
def runtime_readiness(visited: nil)
|
|
20
|
+
RuntimeReadiness.new(self, visited: visited).report
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def transition_snapshots
|
|
20
|
-
transitions.values.map
|
|
24
|
+
transitions.values.map do |transition|
|
|
25
|
+
TransitionSnapshot.from_transition(transition, workflow_class: workflow_class)
|
|
26
|
+
end
|
|
21
27
|
end
|
|
22
28
|
end
|
|
23
29
|
end
|
|
@@ -29,8 +35,17 @@ require_relative "graph/state_diagnostics"
|
|
|
29
35
|
require_relative "graph/reachability"
|
|
30
36
|
require_relative "graph/reachability_diagnostics"
|
|
31
37
|
require_relative "graph/metrics"
|
|
38
|
+
require_relative "graph/nested_readiness_diagnostics"
|
|
32
39
|
require_relative "graph/report"
|
|
40
|
+
require_relative "graph/runtime_binding_diagnostic_builder"
|
|
41
|
+
require_relative "graph/runtime_binding_diagnostics"
|
|
42
|
+
require_relative "graph/runtime_readiness_report"
|
|
43
|
+
require_relative "graph/runtime_readiness_metrics"
|
|
33
44
|
require_relative "graph/targets"
|
|
45
|
+
require_relative "graph/fanout_contract"
|
|
46
|
+
require_relative "graph/optimization_contract"
|
|
47
|
+
require_relative "graph/orchestration_contract"
|
|
34
48
|
require_relative "graph/transition_snapshot"
|
|
35
49
|
require_relative "graph/transition_diagnostics"
|
|
36
50
|
require_relative "graph/validator"
|
|
51
|
+
require_relative "graph/runtime_readiness"
|
|
@@ -20,16 +20,33 @@ module Smith
|
|
|
20
20
|
run_agent_output_guardrails(output, agent_class)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
def handle_step_failure(transition,
|
|
23
|
+
def handle_step_failure(transition, error)
|
|
24
24
|
failure_name = transition.failure_transition
|
|
25
|
-
|
|
25
|
+
raise error unless failure_name
|
|
26
26
|
|
|
27
27
|
fail_transition = self.class.find_transition(failure_name)
|
|
28
|
-
|
|
28
|
+
raise error unless fail_transition
|
|
29
|
+
validate_transition_origin!(fail_transition)
|
|
30
|
+
|
|
31
|
+
if actionable_failure_transition?(fail_transition)
|
|
32
|
+
@next_transition_name = failure_name
|
|
33
|
+
return
|
|
34
|
+
end
|
|
29
35
|
|
|
30
36
|
@state = fail_transition.to
|
|
31
37
|
end
|
|
32
38
|
|
|
39
|
+
def actionable_failure_transition?(transition)
|
|
40
|
+
transition.agent_name ||
|
|
41
|
+
transition.deterministic? ||
|
|
42
|
+
transition.routed? ||
|
|
43
|
+
transition.fanout? ||
|
|
44
|
+
transition.nested? ||
|
|
45
|
+
transition.optimized? ||
|
|
46
|
+
transition.orchestrated? ||
|
|
47
|
+
transition.success_transition
|
|
48
|
+
end
|
|
49
|
+
|
|
33
50
|
def run_workflow_input_guardrails
|
|
34
51
|
wf_guardrails = self.class.guardrails
|
|
35
52
|
Guardrails::Runner.run_inputs(wf_guardrails, @context) if wf_guardrails
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "usage_entry"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Workflow
|
|
5
7
|
module NestedExecution
|
|
@@ -29,10 +31,9 @@ module Smith
|
|
|
29
31
|
# Roll up child totals AND usage_entries BEFORE the failed-step
|
|
30
32
|
# check raises. Previously the rollup only fired on child success
|
|
31
33
|
# — billable agent work inside a failed child was silently
|
|
32
|
-
# dropped from the parent's totals/entries.
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
# incorrectly). Roll up first, then re-raise, so the parent's
|
|
34
|
+
# dropped from the parent's totals/entries. Simple aggregate drift
|
|
35
|
+
# checks can miss this when parent rollups and entries undercount
|
|
36
|
+
# the same way. Roll up first, then re-raise, so the parent's
|
|
36
37
|
# terminal state reflects the child's billable work even when the
|
|
37
38
|
# child failed.
|
|
38
39
|
def handle_child_result(child_result)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
OptimizationState = Struct.new(
|
|
6
|
+
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
|
|
7
|
+
) do
|
|
8
|
+
def initialize(config, prepared_input)
|
|
9
|
+
super(config, prepared_input, nil, nil, nil, nil, nil)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
OrchestrationState = Struct.new(
|
|
6
|
+
:config, :prepared_input, :orchestrator_class, :worker_class, :worker_results
|
|
7
|
+
) do
|
|
8
|
+
def initialize(config, prepared_input)
|
|
9
|
+
super(config, prepared_input, nil, nil, nil)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -1,25 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
require_relative "orchestration_state"
|
|
6
|
+
require_relative "worker_execution"
|
|
5
7
|
|
|
6
8
|
module Smith
|
|
7
9
|
class Workflow
|
|
8
10
|
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
11
|
private
|
|
24
12
|
|
|
25
13
|
def dispatch_step(transition, prepared_input: nil)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Parallel
|
|
6
|
+
CancellationSignal = Struct.new(:cancelled, :mutex) do
|
|
7
|
+
def initialize
|
|
8
|
+
super(false, Mutex.new)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def cancel!
|
|
12
|
+
mutex.synchronize { self.cancelled = true }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancelled?
|
|
16
|
+
mutex.synchronize { cancelled }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -2,26 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "concurrent"
|
|
4
4
|
|
|
5
|
+
require_relative "parallel/cancellation_signal"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
class Workflow
|
|
7
9
|
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
10
|
def self.resolve_branch_count(transition, context)
|
|
23
11
|
count = transition.agent_opts[:count]
|
|
24
|
-
count.respond_to?(:call) ? count.call(context) : (count || 1)
|
|
12
|
+
resolved = count.respond_to?(:call) ? count.call(context) : (count || 1)
|
|
13
|
+
return resolved if resolved.is_a?(Integer) && resolved.positive?
|
|
14
|
+
|
|
15
|
+
raise WorkflowError, "parallel branch count must be a positive integer"
|
|
25
16
|
end
|
|
26
17
|
|
|
27
18
|
def self.execute(branches:)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "usage_entry"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
class Workflow
|
|
5
7
|
module Persistence
|
|
@@ -20,7 +22,7 @@ module Smith
|
|
|
20
22
|
total_tokens: @total_tokens || 0,
|
|
21
23
|
tool_results: @tool_results || [],
|
|
22
24
|
outcome: snapshot_outcome,
|
|
23
|
-
#
|
|
25
|
+
# Durable usage fields. All wrapped in
|
|
24
26
|
# snapshot_value so non-JSON-safe runtime values (e.g.
|
|
25
27
|
# custom Hash details on DeterministicStepFailure) get the
|
|
26
28
|
# same deep-copy treatment as context/session_messages/etc.
|
|
@@ -163,9 +165,9 @@ module Smith
|
|
|
163
165
|
|
|
164
166
|
h = raw.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
165
167
|
{
|
|
166
|
-
transition: h[:transition]
|
|
167
|
-
from: h[:from]
|
|
168
|
-
to: h[:to]
|
|
168
|
+
transition: normalize_transition_name(h[:transition]),
|
|
169
|
+
from: normalize_state_name(h[:from]),
|
|
170
|
+
to: normalize_state_name(h[:to]),
|
|
169
171
|
error_class: h[:error_class],
|
|
170
172
|
error_family: h[:error_family],
|
|
171
173
|
error_message: h[:error_message],
|
|
@@ -255,7 +257,7 @@ module Smith
|
|
|
255
257
|
end
|
|
256
258
|
|
|
257
259
|
def normalize_symbol_fields!(normalized)
|
|
258
|
-
normalized[:state] = normalized[:state]
|
|
260
|
+
normalized[:state] = normalize_state_name(normalized[:state])
|
|
259
261
|
if normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?(:kind)
|
|
260
262
|
normalized[:outcome][:kind] = normalized[:outcome][:kind]&.to_sym
|
|
261
263
|
elsif normalized[:outcome].is_a?(Hash) && normalized[:outcome].key?("kind")
|
|
@@ -263,7 +265,36 @@ module Smith
|
|
|
263
265
|
end
|
|
264
266
|
return unless normalized.key?(:next_transition_name)
|
|
265
267
|
|
|
266
|
-
normalized[:next_transition_name] = normalized[:next_transition_name]
|
|
268
|
+
normalized[:next_transition_name] = normalize_transition_name(normalized[:next_transition_name])
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def normalize_transition_name(value)
|
|
272
|
+
return if value.nil?
|
|
273
|
+
transition = self.class.find_transition(value)
|
|
274
|
+
return transition.name if transition
|
|
275
|
+
return value unless value.is_a?(String)
|
|
276
|
+
|
|
277
|
+
symbolized = value.to_sym
|
|
278
|
+
transition = self.class.find_transition(symbolized)
|
|
279
|
+
return transition.name if transition
|
|
280
|
+
|
|
281
|
+
symbolized
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def normalize_state_name(value)
|
|
285
|
+
return if value.nil?
|
|
286
|
+
return value if declared_state?(value)
|
|
287
|
+
return value unless value.is_a?(String)
|
|
288
|
+
|
|
289
|
+
symbolized = value.to_sym
|
|
290
|
+
return symbolized if declared_state?(symbolized)
|
|
291
|
+
|
|
292
|
+
symbolized
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def declared_state?(value)
|
|
296
|
+
states = self.class.instance_variable_get(:@states) || []
|
|
297
|
+
states.include?(value)
|
|
267
298
|
end
|
|
268
299
|
|
|
269
300
|
def normalize_nested_hashes!(normalized)
|
|
@@ -4,16 +4,23 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class Router
|
|
6
6
|
def self.resolve(classifier_output, config, workflow_class:)
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
output = normalize_output(classifier_output)
|
|
8
|
+
validate!(output, config)
|
|
9
|
+
transition_name = select_transition(output, config)
|
|
9
10
|
validate_transition_exists!(transition_name, workflow_class)
|
|
10
11
|
transition_name
|
|
11
12
|
end
|
|
12
13
|
|
|
14
|
+
def self.normalize_output(output)
|
|
15
|
+
return output unless output.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
output.transform_keys { |key| key.is_a?(String) ? key.to_sym : key }
|
|
18
|
+
end
|
|
19
|
+
|
|
13
20
|
def self.validate!(output, config)
|
|
14
21
|
validate_structure!(output)
|
|
15
22
|
validate_confidence!(output[:confidence])
|
|
16
|
-
validate_route_key!(output[:route]
|
|
23
|
+
validate_route_key!(normalize_route_key(output[:route]), output[:confidence], config)
|
|
17
24
|
end
|
|
18
25
|
|
|
19
26
|
def self.validate_structure!(output)
|
|
@@ -37,12 +44,16 @@ module Smith
|
|
|
37
44
|
|
|
38
45
|
def self.select_transition(output, config)
|
|
39
46
|
if output[:confidence] >= config[:confidence_threshold]
|
|
40
|
-
config[:routes][output[:route]
|
|
47
|
+
config[:routes][normalize_route_key(output[:route])]
|
|
41
48
|
else
|
|
42
49
|
config[:fallback]
|
|
43
50
|
end
|
|
44
51
|
end
|
|
45
52
|
|
|
53
|
+
def self.normalize_route_key(route)
|
|
54
|
+
route.to_s.strip.to_sym
|
|
55
|
+
end
|
|
56
|
+
|
|
46
57
|
def self.validate_transition_exists!(transition_name, workflow_class)
|
|
47
58
|
return if workflow_class.find_transition(transition_name)
|
|
48
59
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
|
+
RunResult = Struct.new(:state, :output, :steps, :total_cost, :total_tokens, :context, :session_messages,
|
|
7
|
+
:tool_results, :outcome, :usage_entries, keyword_init: true) do
|
|
8
|
+
def done?
|
|
9
|
+
state_named?(:done)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def failed?
|
|
13
|
+
state_named?(:failed)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def state_named?(name)
|
|
17
|
+
state == name || state.to_s == name.to_s
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def terminal_output
|
|
21
|
+
output
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def outcome_kind
|
|
25
|
+
outcome&.dig(:kind)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def outcome_payload
|
|
29
|
+
outcome&.dig(:payload)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def last_error
|
|
33
|
+
steps.reverse.map { |step| step[:error] }.compact.first
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def failed_transition
|
|
37
|
+
failure_detail&.fetch(:transition)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failure_detail
|
|
41
|
+
failed_step = steps.reverse.find { |step| step[:error] }
|
|
42
|
+
return nil unless failed_step
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
transition: failed_step[:transition],
|
|
46
|
+
from: failed_step[:from],
|
|
47
|
+
to: failed_step[:to],
|
|
48
|
+
error: failed_step[:error]
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
# rubocop:enable Style/RedundantStructKeywordInit
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -5,7 +5,8 @@ module Smith
|
|
|
5
5
|
class Transition
|
|
6
6
|
attr_reader :name, :from, :to, :agent_name, :agent_opts, :success_transition, :failure_transition,
|
|
7
7
|
:router_config, :workflow_class, :optimization_config, :orchestrator_config,
|
|
8
|
-
:fanout_config, :retry_config, :deterministic_block, :deterministic_kind
|
|
8
|
+
:fanout_config, :retry_config, :deterministic_block, :deterministic_kind,
|
|
9
|
+
:deterministic_routes
|
|
9
10
|
|
|
10
11
|
def initialize(name, from:, to:, &)
|
|
11
12
|
@name = name
|
|
@@ -32,8 +33,12 @@ module Smith
|
|
|
32
33
|
def route(agent_name, routes:, confidence_threshold:, fallback:)
|
|
33
34
|
validate_route_conflicts!
|
|
34
35
|
|
|
35
|
-
@agent_name = agent_name
|
|
36
|
-
@router_config = {
|
|
36
|
+
@agent_name = normalize_agent_name!(agent_name, "router")
|
|
37
|
+
@router_config = {
|
|
38
|
+
routes: normalize_router_routes!(routes),
|
|
39
|
+
confidence_threshold: normalize_confidence_threshold!(confidence_threshold),
|
|
40
|
+
fallback: normalize_transition_reference!(fallback, "router fallback")
|
|
41
|
+
}
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def workflow(klass)
|
|
@@ -104,12 +109,13 @@ module Smith
|
|
|
104
109
|
end
|
|
105
110
|
|
|
106
111
|
%i[compute run].each do |method_name|
|
|
107
|
-
define_method(method_name) do
|
|
112
|
+
define_method(method_name) do |routes: nil, &block|
|
|
108
113
|
validate_deterministic_conflicts!
|
|
109
114
|
raise WorkflowError, "#{method_name} requires a block" unless block
|
|
110
115
|
|
|
111
116
|
@deterministic_block = block
|
|
112
117
|
@deterministic_kind = method_name
|
|
118
|
+
@deterministic_routes = normalize_deterministic_routes!(routes)
|
|
113
119
|
end
|
|
114
120
|
end
|
|
115
121
|
|
|
@@ -241,6 +247,57 @@ module Smith
|
|
|
241
247
|
normalized.freeze
|
|
242
248
|
end
|
|
243
249
|
|
|
250
|
+
def normalize_agent_name!(agent_name, label)
|
|
251
|
+
value = agent_name.to_s.strip
|
|
252
|
+
raise WorkflowError, "#{label} agent must not be blank" if value.empty?
|
|
253
|
+
|
|
254
|
+
value.to_sym
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def normalize_router_routes!(routes)
|
|
258
|
+
raise WorkflowError, "router routes must be a Hash" unless routes.is_a?(Hash)
|
|
259
|
+
raise WorkflowError, "router routes must not be empty" if routes.empty?
|
|
260
|
+
|
|
261
|
+
routes.each_with_object({}) do |(route_key, transition_name), map|
|
|
262
|
+
key = normalize_router_route_key!(route_key)
|
|
263
|
+
raise WorkflowError, "router route :#{key} is duplicated" if map.key?(key)
|
|
264
|
+
|
|
265
|
+
map[key] = normalize_transition_reference!(transition_name, "router route :#{key}")
|
|
266
|
+
end.freeze
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def normalize_router_route_key!(route_key)
|
|
270
|
+
value = route_key.to_s.strip
|
|
271
|
+
raise WorkflowError, "router route keys must not be blank" if value.empty?
|
|
272
|
+
|
|
273
|
+
value.to_sym
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def normalize_confidence_threshold!(threshold)
|
|
277
|
+
numeric = Float(threshold)
|
|
278
|
+
return numeric if numeric >= 0.0 && numeric <= 1.0
|
|
279
|
+
|
|
280
|
+
raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
|
|
281
|
+
rescue TypeError, ArgumentError
|
|
282
|
+
raise WorkflowError, "router confidence_threshold must be a number in 0.0..1.0"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def normalize_transition_reference!(transition_name, label)
|
|
286
|
+
case transition_name
|
|
287
|
+
when Symbol
|
|
288
|
+
raise WorkflowError, "#{label} transition must not be blank" if transition_name.to_s.empty?
|
|
289
|
+
|
|
290
|
+
transition_name
|
|
291
|
+
when String
|
|
292
|
+
value = transition_name.strip
|
|
293
|
+
raise WorkflowError, "#{label} transition must not be blank" if value.empty?
|
|
294
|
+
|
|
295
|
+
value.freeze
|
|
296
|
+
else
|
|
297
|
+
raise WorkflowError, "#{label} transition must be String or Symbol"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
244
301
|
def normalize_fanout_branch_key!(branch_key)
|
|
245
302
|
key = branch_key.to_s.strip
|
|
246
303
|
raise WorkflowError, "fan_out branch keys must not be blank" if key.empty?
|
|
@@ -278,6 +335,35 @@ module Smith
|
|
|
278
335
|
validate_non_negative_numeric!(:max_delay, max_delay) unless max_delay.nil?
|
|
279
336
|
end
|
|
280
337
|
|
|
338
|
+
def normalize_deterministic_routes!(routes)
|
|
339
|
+
return nil if routes.nil?
|
|
340
|
+
raise WorkflowError, "deterministic routes must be an Array" unless routes.is_a?(Array)
|
|
341
|
+
raise WorkflowError, "deterministic routes must not be empty" if routes.empty?
|
|
342
|
+
|
|
343
|
+
routes.each_with_object([]) do |route, list|
|
|
344
|
+
name = normalize_deterministic_route!(route)
|
|
345
|
+
raise WorkflowError, "deterministic route #{name.inspect} is duplicated" if list.include?(name)
|
|
346
|
+
|
|
347
|
+
list << name
|
|
348
|
+
end.freeze
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def normalize_deterministic_route!(route)
|
|
352
|
+
case route
|
|
353
|
+
when Symbol
|
|
354
|
+
raise WorkflowError, "deterministic route names must not be blank" if route.to_s.empty?
|
|
355
|
+
|
|
356
|
+
route
|
|
357
|
+
when String
|
|
358
|
+
value = route.strip
|
|
359
|
+
raise WorkflowError, "deterministic route names must not be blank" if value.empty?
|
|
360
|
+
|
|
361
|
+
value.freeze
|
|
362
|
+
else
|
|
363
|
+
raise WorkflowError, "deterministic route names must be String or Symbol"
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
281
367
|
def validate_non_negative_numeric!(name, value)
|
|
282
368
|
numeric = Float(value)
|
|
283
369
|
return if numeric >= 0.0
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# One row per agent provider call. `usage_id` is a UUID generated at
|
|
6
|
+
# recording time and stable across persist/restore so hosts can use it as an
|
|
7
|
+
# idempotency anchor.
|
|
8
|
+
# rubocop:disable Style/RedundantStructKeywordInit
|
|
9
|
+
UsageEntry = Struct.new(
|
|
10
|
+
:usage_id,
|
|
11
|
+
:agent_name,
|
|
12
|
+
:model,
|
|
13
|
+
:input_tokens,
|
|
14
|
+
:output_tokens,
|
|
15
|
+
:cost,
|
|
16
|
+
:attempt_kind,
|
|
17
|
+
:recorded_at,
|
|
18
|
+
keyword_init: true
|
|
19
|
+
) do
|
|
20
|
+
def self.from_h(hash)
|
|
21
|
+
sym = hash.transform_keys(&:to_sym)
|
|
22
|
+
filtered = sym.slice(*members)
|
|
23
|
+
filtered[:agent_name] = filtered[:agent_name].to_sym if filtered[:agent_name].is_a?(String)
|
|
24
|
+
filtered[:attempt_kind] = filtered[:attempt_kind].to_sym if filtered[:attempt_kind].is_a?(String)
|
|
25
|
+
new(**filtered)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
# rubocop:enable Style/RedundantStructKeywordInit
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
WorkerExecution = Struct.new(:execution_id, :task, :output) do
|
|
8
|
+
def self.run(worker_class, task, schema, budget_runner)
|
|
9
|
+
new(SecureRandom.uuid, task, budget_runner.call(worker_class, task, schema))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|