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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -3
  3. data/README.md +45 -2
  4. data/UPSTREAM_PROPOSAL.md +19 -0
  5. data/docs/PATTERNS.md +41 -1
  6. data/lib/smith/agent/registry/introspection.rb +27 -0
  7. data/lib/smith/agent/registry.rb +22 -17
  8. data/lib/smith/agent/registry_binding.rb +43 -0
  9. data/lib/smith/agent.rb +3 -4
  10. data/lib/smith/doctor/checks/models_registry.rb +25 -14
  11. data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
  12. data/lib/smith/version.rb +1 -1
  13. data/lib/smith/workflow/agent_result.rb +26 -0
  14. data/lib/smith/workflow/branch_env.rb +24 -0
  15. data/lib/smith/workflow/budget_integration.rb +2 -0
  16. data/lib/smith/workflow/deterministic_execution.rb +1 -1
  17. data/lib/smith/workflow/deterministic_step.rb +32 -4
  18. data/lib/smith/workflow/durability.rb +38 -12
  19. data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
  20. data/lib/smith/workflow/event_integration.rb +3 -3
  21. data/lib/smith/workflow/execution.rb +2 -0
  22. data/lib/smith/workflow/fanout_execution.rb +2 -0
  23. data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
  24. data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
  25. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
  26. data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
  27. data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
  28. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
  29. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
  30. data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
  31. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
  32. data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
  33. data/lib/smith/workflow/graph/targets.rb +5 -0
  34. data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
  35. data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
  36. data/lib/smith/workflow/graph.rb +16 -1
  37. data/lib/smith/workflow/graph_dsl.rb +4 -0
  38. data/lib/smith/workflow/guardrail_integration.rb +20 -3
  39. data/lib/smith/workflow/nested_execution.rb +5 -4
  40. data/lib/smith/workflow/optimization_state.rb +13 -0
  41. data/lib/smith/workflow/orchestration_state.rb +13 -0
  42. data/lib/smith/workflow/orchestrator_worker.rb +3 -15
  43. data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
  44. data/lib/smith/workflow/parallel.rb +6 -15
  45. data/lib/smith/workflow/parallel_execution.rb +2 -0
  46. data/lib/smith/workflow/persistence.rb +37 -6
  47. data/lib/smith/workflow/router.rb +15 -4
  48. data/lib/smith/workflow/run_result.rb +54 -0
  49. data/lib/smith/workflow/transition.rb +90 -4
  50. data/lib/smith/workflow/usage_entry.rb +30 -0
  51. data/lib/smith/workflow/worker_execution.rb +13 -0
  52. data/lib/smith/workflow.rb +40 -136
  53. data/lib/smith.rb +9 -0
  54. 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:, transition_name:)
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
- # Hadithi uses this to skip the credits guard at execution time
36
- # when persisted state already exists for a workflow key (a
37
- # prior attempt's billable work is durable in Redis, OR an
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 the credits guard's bypass keys on `persisted_state_exists?`
55
- # alone, a zero-balance user's retry on that abandoned init
56
- # state silently runs the first model call (the guard is
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.transitions_from(state_name.to_sym).empty? && next_transition.nil?
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
- step = advance!
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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "agent_result"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module Execution
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "branch_env"
4
+
3
5
  module Smith
4
6
  class Workflow
5
7
  module FanoutExecution
@@ -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