smith-agents 0.4.1 → 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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +91 -3
  3. data/README.md +69 -3
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +85 -6
  6. data/lib/smith/agent/lifecycle.rb +11 -7
  7. data/lib/smith/agent/registry/introspection.rb +27 -0
  8. data/lib/smith/agent/registry.rb +22 -17
  9. data/lib/smith/agent/registry_binding.rb +43 -0
  10. data/lib/smith/agent.rb +3 -4
  11. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  12. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  13. data/lib/smith/version.rb +1 -1
  14. data/lib/smith/workflow/agent_result.rb +26 -0
  15. data/lib/smith/workflow/branch_env.rb +24 -0
  16. data/lib/smith/workflow/budget_integration.rb +27 -5
  17. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  18. data/lib/smith/workflow/deterministic_step.rb +32 -4
  19. data/lib/smith/workflow/durability.rb +38 -12
  20. data/lib/smith/workflow/evaluator_optimizer.rb +18 -10
  21. data/lib/smith/workflow/event_integration.rb +3 -3
  22. data/lib/smith/workflow/execution.rb +8 -1
  23. data/lib/smith/workflow/fanout_execution.rb +121 -0
  24. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  25. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  26. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  27. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  28. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  30. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  31. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  33. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  34. data/lib/smith/workflow/graph/targets.rb +5 -0
  35. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  36. data/lib/smith/workflow/graph/transition_snapshot.rb +80 -18
  37. data/lib/smith/workflow/graph.rb +16 -1
  38. data/lib/smith/workflow/graph_dsl.rb +4 -0
  39. data/lib/smith/workflow/guardrail_integration.rb +45 -10
  40. data/lib/smith/workflow/nested_execution.rb +5 -4
  41. data/lib/smith/workflow/optimization_state.rb +13 -0
  42. data/lib/smith/workflow/orchestration_state.rb +13 -0
  43. data/lib/smith/workflow/orchestrator_worker.rb +5 -16
  44. data/lib/smith/workflow/parallel/cancellation.rb +9 -0
  45. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  46. data/lib/smith/workflow/parallel.rb +12 -16
  47. data/lib/smith/workflow/parallel_execution.rb +5 -1
  48. data/lib/smith/workflow/persistence.rb +37 -6
  49. data/lib/smith/workflow/retry_execution.rb +52 -0
  50. data/lib/smith/workflow/router.rb +15 -4
  51. data/lib/smith/workflow/run_result.rb +54 -0
  52. data/lib/smith/workflow/transition.rb +260 -24
  53. data/lib/smith/workflow/usage_entry.rb +30 -0
  54. data/lib/smith/workflow/worker_execution.rb +13 -0
  55. data/lib/smith/workflow.rb +40 -136
  56. data/lib/smith.rb +12 -0
  57. metadata +24 -1
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "branch_env"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module FanoutExecution
8
+ private
9
+
10
+ def run_guarded_fanout_step(transition)
11
+ branches = transition.fanout_config.fetch(:branches)
12
+ branch_agent_classes = fanout_agent_classes(transition, branches)
13
+ run_workflow_input_guardrails
14
+ run_fanout_agent_input_guardrails(branch_agent_classes)
15
+ prepared_input = build_session&.prepare!
16
+ output = execute_fanout_step(
17
+ transition,
18
+ branches: branches,
19
+ branch_agent_classes: branch_agent_classes,
20
+ prepared_input: prepared_input
21
+ )
22
+ run_workflow_output_guardrails(output)
23
+ output
24
+ end
25
+
26
+ def execute_fanout_step(transition, branches: nil, branch_agent_classes: nil, prepared_input: nil)
27
+ branches ||= transition.fanout_config.fetch(:branches)
28
+ branch_agent_classes ||= fanout_agent_classes(transition, branches)
29
+ env = BranchEnv.new(
30
+ prepared_input: prepared_input,
31
+ guardrail_sources: nil,
32
+ scoped_store: propagate_scoped_artifacts,
33
+ branch_estimates: fanout_branch_estimates(branches, branch_agent_classes),
34
+ deadline: wall_clock_deadline
35
+ )
36
+
37
+ branch_calls = branches.map do |branch_key, agent_name|
38
+ proc do |signal|
39
+ run_fanout_branch(branch_key, agent_name, branch_agent_classes.fetch(branch_key), env, signal)
40
+ end
41
+ end
42
+
43
+ Parallel.execute(branches: branch_calls)
44
+ end
45
+
46
+ def run_fanout_branch(branch_key, agent_name, agent_class, env, signal)
47
+ setup_fanout_branch_context(env, @ledger, agent_class)
48
+
49
+ with_agent_context(agent_class) do
50
+ branch_ledger = effective_call_ledger
51
+ reserved = reserve_fanout_branch_call(branch_ledger, env.branch_estimates[branch_key], agent_class)
52
+ begin
53
+ result = guarded_fanout_branch_call(agent_class, env, signal)
54
+ finalize_named_branch(branch_key, agent_name, result, branch_ledger, reserved).tap { reserved = nil }
55
+ ensure
56
+ settle_budget_on_failure(branch_ledger, reserved, Thread.current[:smith_last_agent_result]) if reserved
57
+ end
58
+ end
59
+ ensure
60
+ teardown_branch_context(env)
61
+ end
62
+
63
+ def guarded_fanout_branch_call(agent_class, env, signal)
64
+ check_cancellation!(signal)
65
+ check_deadline!
66
+ result = agent_class.model_configured? ? invoke_agent(agent_class, env.prepared_input) : nil
67
+ output = result.is_a?(AgentResult) ? result.content : result
68
+ validate_data_volume!(output, agent_class)
69
+ run_agent_output_guardrails(output, agent_class)
70
+ check_cancellation!(signal)
71
+ result
72
+ end
73
+
74
+ def setup_fanout_branch_context(env, ledger, agent_class)
75
+ setup_branch_context(env, ledger)
76
+ apply_tool_guardrails(agent_class)
77
+ end
78
+
79
+ def reserve_fanout_branch_call(branch_ledger, branch_estimates, agent_class)
80
+ return reserve_branch_budget(branch_ledger, branch_estimates: branch_estimates) if @ledger
81
+
82
+ reserve_serial_budget(branch_ledger, agent_budget: agent_class&.budget) if branch_ledger
83
+ end
84
+
85
+ def fanout_agent_classes(transition, branches)
86
+ branches.to_h do |branch_key, agent_name|
87
+ [branch_key, resolve_fanout_agent_class(transition, agent_name)]
88
+ end
89
+ end
90
+
91
+ def run_fanout_agent_input_guardrails(branch_agent_classes)
92
+ branch_agent_classes.each_value do |agent_class|
93
+ run_agent_input_guardrails(agent_class)
94
+ end
95
+ end
96
+
97
+ def fanout_branch_estimates(branches, branch_agent_classes)
98
+ return {} unless @ledger
99
+
100
+ branch_count = branches.length
101
+ branches.each_with_object({}) do |(branch_key, _agent_name), map|
102
+ agent_class = branch_agent_classes.fetch(branch_key)
103
+ map[branch_key] = compute_branch_estimates(
104
+ @ledger,
105
+ branch_count: branch_count,
106
+ agent_budget: agent_class&.budget
107
+ )
108
+ end
109
+ end
110
+
111
+ def resolve_fanout_agent_class(transition, agent_name)
112
+ Agent::Registry.fetch!(
113
+ agent_name,
114
+ workflow_class: self.class,
115
+ transition_name: transition&.name,
116
+ role: :fanout_agent
117
+ )
118
+ end
119
+ end
120
+ end
121
+ 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
@@ -0,0 +1,83 @@
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 OrchestrationContract
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.orchestrator_config
19
+ end
20
+
21
+ def to_h
22
+ deep_freeze(
23
+ transition_contract.merge(
24
+ decision_contract: decision_contract,
25
+ output_contract: output_contract,
26
+ resume_contract: resume_contract
27
+ )
28
+ )
29
+ end
30
+
31
+ private
32
+
33
+ def config
34
+ transition.orchestrator_config
35
+ end
36
+
37
+ def transition_contract
38
+ {
39
+ orchestrator: immutable_value(config.fetch(:orchestrator)),
40
+ worker: immutable_value(config.fetch(:worker)),
41
+ max_workers: config.fetch(:max_workers),
42
+ max_delegation_rounds: config.fetch(:max_delegation_rounds),
43
+ worker_dispatch: :serial
44
+ }.merge(schema_contract)
45
+ end
46
+
47
+ def schema_contract
48
+ {
49
+ task_schema: label_for(config.fetch(:task_schema)),
50
+ worker_output_schema: label_for(config.fetch(:worker_output_schema)),
51
+ final_output_schema: label_for(config.fetch(:final_output_schema))
52
+ }
53
+ end
54
+
55
+ def decision_contract
56
+ {
57
+ shape: :hash,
58
+ exactly_one_of: %i[tasks final stop],
59
+ tasks_limit: config.fetch(:max_workers)
60
+ }
61
+ end
62
+
63
+ def output_contract
64
+ {
65
+ success: :final_output_schema,
66
+ worker_result_shape: :execution_id_task_output,
67
+ failure: :workflow_error_on_stop_exhaustion_or_schema_violation
68
+ }
69
+ end
70
+
71
+ def resume_contract
72
+ {
73
+ granularity: :transition,
74
+ round_checkpointing: false,
75
+ worker_checkpointing: false,
76
+ idempotency_mode: idempotency_mode,
77
+ in_flight_resume: in_flight_resume
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end