smith-agents 0.6.1 → 0.8.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.
Files changed (122) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -1
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +22 -1
  5. data/docs/PERSISTENCE.md +27 -1
  6. data/docs/TOOLS_AND_GUARDRAILS.md +293 -2
  7. data/lib/smith/agent/chat_construction.rb +112 -0
  8. data/lib/smith/agent/completion.rb +46 -0
  9. data/lib/smith/agent/completion_usage_recording.rb +25 -0
  10. data/lib/smith/agent/dynamic_configuration.rb +69 -0
  11. data/lib/smith/agent/fallback_configuration.rb +26 -0
  12. data/lib/smith/agent/invocation_preparation.rb +104 -0
  13. data/lib/smith/agent/lifecycle.rb +7 -276
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +28 -0
  16. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  17. data/lib/smith/agent/provider_completion.rb +130 -0
  18. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  19. data/lib/smith/agent/provider_usage.rb +28 -0
  20. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  21. data/lib/smith/agent/usage_entry_recording.rb +43 -0
  22. data/lib/smith/agent/usage_tracking.rb +113 -0
  23. data/lib/smith/agent.rb +31 -177
  24. data/lib/smith/context/observation_masking.rb +28 -4
  25. data/lib/smith/context/session.rb +7 -2
  26. data/lib/smith/diagnostic_text.rb +67 -0
  27. data/lib/smith/doctor/checks/configuration.rb +16 -3
  28. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  29. data/lib/smith/errors.rb +34 -1
  30. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  31. data/lib/smith/models/collision_error.rb +9 -0
  32. data/lib/smith/models/inference.rb +14 -0
  33. data/lib/smith/models/normalizer.rb +11 -67
  34. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  35. data/lib/smith/models/tool_routing.rb +140 -0
  36. data/lib/smith/models.rb +24 -78
  37. data/lib/smith/persisted_failure_invalid.rb +7 -0
  38. data/lib/smith/pricing.rb +138 -17
  39. data/lib/smith/pricing_configuration_error.rb +7 -0
  40. data/lib/smith/provider_permanent_failure.rb +16 -0
  41. data/lib/smith/tool/argument_container_reader.rb +97 -0
  42. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  43. data/lib/smith/tool/argument_snapshot.rb +19 -0
  44. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  45. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  46. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  47. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  48. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  49. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  50. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  51. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  52. data/lib/smith/tool/budget_enforcement.rb +17 -11
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +134 -0
  55. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  56. data/lib/smith/tool/call_batch.rb +47 -0
  57. data/lib/smith/tool/call_budget.rb +51 -0
  58. data/lib/smith/tool/call_reservation.rb +42 -0
  59. data/lib/smith/tool/capture.rb +60 -4
  60. data/lib/smith/tool/capture_configuration.rb +20 -0
  61. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  62. data/lib/smith/tool/chat_execution_context.rb +24 -0
  63. data/lib/smith/tool/execution_authority.rb +42 -0
  64. data/lib/smith/tool/execution_authorization.rb +17 -0
  65. data/lib/smith/tool/execution_batch.rb +100 -0
  66. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  67. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  68. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  69. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  70. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  71. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  72. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  73. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  74. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  75. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  76. data/lib/smith/tool/execution_batch_state.rb +110 -0
  77. data/lib/smith/tool/execution_dispatch.rb +78 -0
  78. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  79. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  80. data/lib/smith/tool/execution_tracker.rb +20 -0
  81. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  82. data/lib/smith/tool/graceful_completion.rb +20 -0
  83. data/lib/smith/tool/invocation.rb +27 -0
  84. data/lib/smith/tool/invocation_request.rb +37 -0
  85. data/lib/smith/tool/invocation_sequence.rb +26 -0
  86. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  87. data/lib/smith/tool/scoped_context.rb +123 -0
  88. data/lib/smith/tool.rb +78 -57
  89. data/lib/smith/tool_capture_failed.rb +106 -0
  90. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  91. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  92. data/lib/smith/version.rb +2 -2
  93. data/lib/smith/workflow/agent_result.rb +4 -3
  94. data/lib/smith/workflow/artifact_integration.rb +2 -1
  95. data/lib/smith/workflow/composite/branch_failure.rb +34 -13
  96. data/lib/smith/workflow/composite/branch_outcome.rb +19 -2
  97. data/lib/smith/workflow/composite/effects.rb +3 -1
  98. data/lib/smith/workflow/composite/error.rb +53 -5
  99. data/lib/smith/workflow/composite/error_evidence.rb +10 -2
  100. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  101. data/lib/smith/workflow/execution.rb +2 -9
  102. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  103. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  104. data/lib/smith/workflow/failure_record.rb +139 -0
  105. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  106. data/lib/smith/workflow/failure_record_text.rb +83 -0
  107. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  108. data/lib/smith/workflow/fanout_execution.rb +12 -11
  109. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  110. data/lib/smith/workflow/parallel/cancellation_signal.rb +1 -1
  111. data/lib/smith/workflow/parallel/root_execution.rb +1 -1
  112. data/lib/smith/workflow/parallel.rb +17 -1
  113. data/lib/smith/workflow/persistence.rb +22 -19
  114. data/lib/smith/workflow/prepared_branch_execution.rb +9 -2
  115. data/lib/smith/workflow/retry_execution.rb +5 -0
  116. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +13 -4
  117. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  118. data/lib/smith/workflow/transition.rb +11 -0
  119. data/lib/smith/workflow/usage_entry.rb +12 -2
  120. data/lib/smith/workflow.rb +17 -124
  121. data/lib/smith.rb +3 -0
  122. metadata +83 -5
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module ProviderFailureHandling
6
+ TRANSIENT_ERRORS = [
7
+ RubyLLM::ServerError,
8
+ RubyLLM::ServiceUnavailableError,
9
+ RubyLLM::OverloadedError,
10
+ RubyLLM::RateLimitError
11
+ ].freeze
12
+ MODEL_UNAVAILABLE_STATUSES = [404, 410].freeze
13
+ PROVIDER_ACCOUNT_ERRORS = [
14
+ RubyLLM::UnauthorizedError,
15
+ RubyLLM::PaymentRequiredError
16
+ ].freeze
17
+ MODEL_PERMISSION_ERRORS = [RubyLLM::ForbiddenError].freeze
18
+
19
+ private_constant :TRANSIENT_ERRORS, :MODEL_UNAVAILABLE_STATUSES,
20
+ :PROVIDER_ACCOUNT_ERRORS, :MODEL_PERMISSION_ERRORS
21
+
22
+ private
23
+
24
+ def handle_provider_failure!(error, model_reference, agent_class, fallback_available:)
25
+ account_failed_attempt(error, model_reference, agent_class)
26
+ if completed_tool_calls?
27
+ raise Smith::ToolOutcomeUncertain.new(
28
+ "provider failed after tool execution began; retry or fallback could replay an uncertain outcome"
29
+ ), cause: error
30
+ end
31
+ return if fallback_eligible?(error) && fallback_available
32
+
33
+ raise terminal_provider_error(error, model_reference), cause: error
34
+ end
35
+
36
+ def provider_failure?(error)
37
+ error.is_a?(RubyLLM::Error) ||
38
+ (defined?(RubyLLM::ModelNotFoundError) && error.is_a?(RubyLLM::ModelNotFoundError)) ||
39
+ error.is_a?(Faraday::TimeoutError) ||
40
+ error.is_a?(Faraday::ConnectionFailed)
41
+ end
42
+
43
+ def provider_account_failure?(error)
44
+ PROVIDER_ACCOUNT_ERRORS.any? { |error_class| error.is_a?(error_class) }
45
+ end
46
+
47
+ def completed_tool_calls?
48
+ Tool.current_tool_execution_tracker&.started?
49
+ end
50
+
51
+ def fallback_eligible?(error)
52
+ transient_failure?(error) ||
53
+ transport_failure?(error) ||
54
+ provider_account_failure?(error) ||
55
+ model_permission_failure?(error) ||
56
+ model_unavailable?(error)
57
+ end
58
+
59
+ def transient_failure?(error)
60
+ TRANSIENT_ERRORS.any? { |error_class| error.is_a?(error_class) }
61
+ end
62
+
63
+ def transport_failure?(error)
64
+ error.is_a?(Faraday::TimeoutError) || error.is_a?(Faraday::ConnectionFailed)
65
+ end
66
+
67
+ def model_permission_failure?(error)
68
+ MODEL_PERMISSION_ERRORS.any? { |error_class| error.is_a?(error_class) }
69
+ end
70
+
71
+ def model_unavailable?(error)
72
+ return true if defined?(RubyLLM::ModelNotFoundError) && error.is_a?(RubyLLM::ModelNotFoundError)
73
+ return false unless error.respond_to?(:response)
74
+
75
+ response = error.response
76
+ response.respond_to?(:status) && MODEL_UNAVAILABLE_STATUSES.include?(response.status.to_i)
77
+ rescue StandardError
78
+ false
79
+ end
80
+
81
+ def terminal_provider_error(error, model_reference)
82
+ return Smith::AgentError.new(error.message) if transient_failure?(error) || transport_failure?(error)
83
+
84
+ Smith::ProviderPermanentFailure.new(
85
+ error.message,
86
+ provider: model_reference.provider,
87
+ model_id: model_reference.model_id,
88
+ source_error_class: error.class.name
89
+ )
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "../types"
6
+
7
+ module Smith
8
+ class Agent
9
+ class ProviderUsage < Dry::Struct
10
+ attribute :input_tokens, Types::Integer
11
+ attribute :output_tokens, Types::Integer
12
+
13
+ def self.from_message(message)
14
+ return unless message.respond_to?(:input_tokens) && message.respond_to?(:output_tokens)
15
+
16
+ input_tokens = message.input_tokens
17
+ output_tokens = message.output_tokens
18
+ return unless input_tokens.is_a?(Integer) && output_tokens.is_a?(Integer)
19
+
20
+ new(input_tokens:, output_tokens:)
21
+ end
22
+
23
+ def total_tokens
24
+ input_tokens + output_tokens
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module ReservedInputBridge
6
+ private
7
+
8
+ def partition_inputs(kwargs)
9
+ input_values, chat_options = super
10
+ provider = input_values[:provider]
11
+ chat_options[:provider] = provider if provider && provider.to_sym != :unknown
12
+ [input_values, chat_options]
13
+ end
14
+
15
+ def apply_configuration(chat_object, input_values:, persist_instructions:)
16
+ super(
17
+ chat_object,
18
+ input_values: reserved_inputs_for(chat_object, input_values),
19
+ persist_instructions:
20
+ )
21
+ end
22
+
23
+ def reserved_inputs_for(chat_object, input_values)
24
+ llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
25
+ model = llm_chat.model if llm_chat.respond_to?(:model)
26
+ profile = resolve_profile(
27
+ model&.id || input_values[:model_id],
28
+ provider: actual_provider(llm_chat) || input_values[:provider]
29
+ )
30
+ return input_values unless profile
31
+
32
+ input_values.merge(
33
+ model_id: profile.model_id,
34
+ provider: profile.provider,
35
+ endpoint_mode: profile.endpoint_mode
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ module UsageEntryRecording
6
+ private
7
+
8
+ def record_usage(agent_class, agent_result, attempt_kind, model_reference)
9
+ return unless agent_result.usage_known?
10
+
11
+ model_reference = coerce_model_reference(model_reference)
12
+ entry = build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
13
+ accumulate_usage(agent_result, entry)
14
+ end
15
+
16
+ def build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
17
+ Workflow::UsageEntry.new(
18
+ usage_id: SecureRandom.uuid,
19
+ agent_name: agent_class.register_as,
20
+ model: model_reference.model_id,
21
+ provider: model_reference.provider,
22
+ input_tokens: agent_result.input_tokens,
23
+ output_tokens: agent_result.output_tokens,
24
+ cost: agent_result.cost,
25
+ attempt_kind: attempt_kind,
26
+ recorded_at: Time.now.utc.iso8601
27
+ )
28
+ end
29
+
30
+ def accumulate_usage(agent_result, entry)
31
+ @usage_mutex.synchronize do
32
+ @total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
33
+ @total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
34
+ @usage_entries << entry
35
+ end
36
+ end
37
+
38
+ def coerce_model_reference(value)
39
+ ModelReference.coerce(value)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "completion_usage_recording"
4
+ require_relative "usage_entry_recording"
5
+
6
+ module Smith
7
+ class Agent
8
+ module UsageTracking
9
+ include CompletionUsageRecording
10
+ include UsageEntryRecording
11
+
12
+ private
13
+
14
+ def account_failed_attempt(error, model_reference, agent_class)
15
+ return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
16
+
17
+ input = error.input_tokens
18
+ output = error.output_tokens
19
+ return unless input.is_a?(Integer) && output.is_a?(Integer)
20
+
21
+ record_failed_usage(agent_class, model_reference, input, output, :failed_attempt)
22
+ end
23
+
24
+ def account_completed_prefix(agent_class, model_reference, messages)
25
+ completion = Completion.from_messages(response: nil, messages: messages)
26
+ record_completion_usage(agent_class, completion, :partial_attempt, model_reference)
27
+ end
28
+
29
+ def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_kind)
30
+ model_reference = coerce_model_reference(model_reference)
31
+ cost = Smith::Pricing.compute_cost(
32
+ model: model_reference.model_id,
33
+ provider: model_reference.provider,
34
+ input_tokens:,
35
+ output_tokens:
36
+ )
37
+ agent_result = Workflow::AgentResult.new(
38
+ content: nil,
39
+ input_tokens:,
40
+ output_tokens:,
41
+ cost: cost,
42
+ model_used: model_reference.model_id,
43
+ provider_used: model_reference.provider
44
+ )
45
+ Thread.current[:smith_failed_agent_results] ||= []
46
+ Thread.current[:smith_failed_agent_results] << agent_result
47
+ record_usage(agent_class, agent_result, attempt_kind, model_reference)
48
+ end
49
+
50
+ def snapshot_and_finalize(agent_class, completion, model_reference)
51
+ model_reference = coerce_model_reference(model_reference)
52
+ agent_result = Workflow::AgentResult.new(
53
+ content: completion.content,
54
+ input_tokens: completion.input_tokens,
55
+ output_tokens: completion.output_tokens,
56
+ cost: nil,
57
+ model_used: model_reference.model_id,
58
+ provider_used: model_reference.provider
59
+ )
60
+ Thread.current[:smith_last_agent_result] = agent_result
61
+ emit_token_usage(agent_result)
62
+ compute_agent_cost(agent_result)
63
+ record_completion_usage(agent_class, completion, :completed_attempt, model_reference)
64
+
65
+ agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
66
+ raise_blank_output!(agent_class, agent_result)
67
+ agent_result
68
+ end
69
+
70
+ def raise_blank_output!(agent_class, agent_result)
71
+ return unless blank_agent_output?(agent_result.content)
72
+
73
+ raise Smith::BlankAgentOutputError.new(
74
+ agent_name: agent_class.register_as,
75
+ model_used: agent_result.model_used
76
+ )
77
+ end
78
+
79
+ def blank_agent_output?(content)
80
+ return true if content.nil?
81
+ return content.strip.empty? if content.is_a?(String)
82
+
83
+ false
84
+ end
85
+
86
+ def emit_token_usage(agent_result)
87
+ return unless agent_result.usage_known?
88
+
89
+ Smith::Trace.record(
90
+ type: :token_usage,
91
+ data: {
92
+ input_tokens: agent_result.input_tokens,
93
+ output_tokens: agent_result.output_tokens,
94
+ model: agent_result.model_used,
95
+ provider: agent_result.provider_used
96
+ }.compact
97
+ )
98
+ end
99
+
100
+ def compute_agent_cost(agent_result)
101
+ return unless agent_result.usage_known?
102
+
103
+ model = agent_result.model_used
104
+ agent_result.cost = Smith::Pricing.compute_cost(
105
+ model: model,
106
+ provider: agent_result.provider_used,
107
+ input_tokens: agent_result.input_tokens,
108
+ output_tokens: agent_result.output_tokens
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
data/lib/smith/agent.rb CHANGED
@@ -2,10 +2,18 @@
2
2
 
3
3
  require "ruby_llm"
4
4
 
5
+ require_relative "tool"
6
+
5
7
  module Smith
6
8
  class Agent < RubyLLM::Agent
9
+ require_relative "agent/model_reference"
10
+
7
11
  EXECUTION_IDENTITY_UNSET = Object.new.freeze
12
+ TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
13
+ TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
14
+
8
15
  private_constant :EXECUTION_IDENTITY_UNSET
16
+ private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
9
17
 
10
18
  # Reserved input names auto-injected by the normalizer into
11
19
  # runtime_context. User-side `inputs :name` calls cannot redeclare
@@ -21,8 +29,9 @@ module Smith
21
29
  subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
22
30
  subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
23
31
  subclass.instance_variable_set(:@data_volume, @data_volume)
24
- subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup)
32
+ subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
25
33
  subclass.instance_variable_set(:@model_block, @model_block)
34
+ subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
26
35
  subclass.instance_variable_set(:@execution_identity, nil)
27
36
  subclass.instance_variable_set(:@registered_name, nil)
28
37
  end
@@ -42,6 +51,17 @@ module Smith
42
51
  @budget_config = opts
43
52
  end
44
53
 
54
+ def tool_budget_exhaustion(value = TOOL_BUDGET_EXHAUSTION_UNSET)
55
+ return @tool_budget_exhaustion || :raise if value.equal?(TOOL_BUDGET_EXHAUSTION_UNSET)
56
+
57
+ policy = value.respond_to?(:to_sym) ? value.to_sym : value
58
+ unless TOOL_BUDGET_EXHAUSTION_POLICIES.include?(policy)
59
+ raise ArgumentError, "tool_budget_exhaustion must be :raise or :complete"
60
+ end
61
+
62
+ @tool_budget_exhaustion = policy
63
+ end
64
+
45
65
  def guardrails(klass = nil)
46
66
  return @guardrails_class if klass.nil?
47
67
 
@@ -60,15 +80,6 @@ module Smith
60
80
  @data_volume = value
61
81
  end
62
82
 
63
- def fallback_models(*models)
64
- return @fallback_models_list if models.empty?
65
-
66
- entries = models.flatten.compact.map(&:to_s)
67
- raise Smith::WorkflowError, "fallback_models entries must not be blank" if entries.any?(&:empty?)
68
-
69
- @fallback_models_list = entries.uniq
70
- end
71
-
72
83
  def register_as(name = nil, publish: true)
73
84
  return @registered_name if name.nil?
74
85
 
@@ -85,52 +96,6 @@ module Smith
85
96
  Registry.ensure_registered(name.to_sym, self)
86
97
  end
87
98
 
88
- # Extends RubyLLM::Agent.model with a block-form for context-driven
89
- # resolution at chat-construction time.
90
- #
91
- # Static form `model "gpt-5-mini"`:
92
- # Stores into @chat_kwargs[:model] via RubyLLM's existing path.
93
- # Model id is fixed at class-load time.
94
- #
95
- # Block form `model { |context| ... }`:
96
- # Stores the block as @model_block. Smith's lifecycle resolves it
97
- # at chat-construction time using the workflow's @context (Hash).
98
- # Return value must be a non-empty string; non-string / empty / nil
99
- # returns surface as Smith::AgentError at the resolution point
100
- # (see Smith::Agent::Lifecycle#build_model_chain).
101
- #
102
- # Mutually exclusive within a single declaration: passing both a
103
- # string id and a block raises ArgumentError. Redeclaring with the
104
- # other form clears the previous setting (static replaces block,
105
- # block replaces static).
106
- #
107
- # Composes with `fallback_models`: resolved primary, then declared
108
- # fallbacks, in order. Same path as static-form fallback.
109
- def model(model_id = nil, **options, &block)
110
- if block
111
- raise ArgumentError, "model can take a string id OR a block, not both" if model_id || !options.empty?
112
-
113
- @model_block = block
114
- # Clear any stale `@chat_kwargs[:model]` from a prior static-form
115
- # declaration. Smith's workflow lifecycle resolves block-form
116
- # correctly via `build_model_chain` (which checks @model_block
117
- # first), but RubyLLM's direct `chat()` and `with_rails_chat_record`
118
- # paths splat `**chat_kwargs` to the constructor; without this
119
- # delete, those paths would silently use the stale static id.
120
- # This is the only place Smith mutates a RubyLLM-owned ivar; the
121
- # mutation is well-scoped (only :model, only on block-form
122
- # declaration) and matches RubyLLM's own pattern of dup'ing
123
- # @chat_kwargs through its `inherited` hook.
124
- @chat_kwargs ||= {}
125
- @chat_kwargs.delete(:model)
126
- else
127
- @model_block = nil
128
- super
129
- end
130
- end
131
-
132
- attr_reader :model_block
133
-
134
99
  private
135
100
 
136
101
  def canonical_registration_name(name)
@@ -144,13 +109,6 @@ module Smith
144
109
 
145
110
  public
146
111
 
147
- # Whether this agent class has any model configured (static or block).
148
- # Smith::Workflow::Execution uses this as a precondition for invoking
149
- # the agent; agents declared without a model are skipped.
150
- def model_configured?
151
- !chat_kwargs[:model].nil? || !@model_block.nil?
152
- end
153
-
154
112
  # MERGING override: getter always returns user-declared ∪ reserved;
155
113
  # setter validates user names against reserved + stores only user
156
114
  # names. RubyLLM's bare `@input_names = names` (agent.rb:96) REPLACES;
@@ -174,120 +132,16 @@ module Smith
174
132
 
175
133
  @input_names = user_names.freeze
176
134
  end
177
-
178
- # Closes the `inputs` contract at the chat() boundary AND runs the
179
- # Smith::Models::Normalizer. Hook lives here (not in
180
- # Lifecycle#attempt_model) so direct callers that construct a chat
181
- # outside the workflow lifecycle are normalized too. Without this
182
- # placement, model-specific request translation would only fire for
183
- # workflow-driven calls.
184
- #
185
- # Single profile lookup: resolved once via Models.find_or_infer and
186
- # passed through both inject_reserved_inputs and Normalizer.apply!.
187
- def chat(**kwargs)
188
- # Resolve model from explicit kwarg first, then fall back to the
189
- # class-level chat_kwargs[:model] (set by `model "..."`). The
190
- # explicit kwarg path fires from Lifecycle#attempt_model (passes
191
- # the resolved primary or fallback model); the chat_kwargs path
192
- # fires from direct callers like `Agent.chat` with no args.
193
- model_id = kwargs[:model] || chat_kwargs[:model]
194
- profile = resolve_profile(model_id)
195
- kwargs = inject_reserved_inputs(kwargs, profile)
196
- kwargs = nil_fill_declared_inputs(kwargs)
197
-
198
- llm_chat = super
199
- Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
200
- llm_chat
201
- end
202
-
203
- # Normalizes the |ctx| DSL across RubyLLM's block-form attribute setters.
204
- #
205
- # RubyLLM evaluates these blocks via `runtime.instance_exec(&block)`,
206
- # which sets `self` to the runtime_context but passes NO positional
207
- # arguments, so `tools do |ctx| ctx.form_kind end` would silently
208
- # bind `ctx = nil` and crash on the first method call. Smith's `model`
209
- # block-form already uses `block.call(@context)` (an explicit Hash arg),
210
- # giving agent authors a uniform `|ctx|` mental model. These overrides
211
- # carry that convention through to RubyLLM's setters by wrapping any
212
- # block so `|ctx|` receives the runtime_context AND `self` is still the
213
- # runtime (preserving RubyLLM's bare-method-dispatch convention for
214
- # zero-arity blocks).
215
- #
216
- # Behavior matrix:
217
- # tools do ... end (arity 0): preserved as-is; bare method
218
- # calls dispatch to runtime via
219
- # instance_exec (RubyLLM idiom)
220
- # tools do |ctx| ... end (arity 1): wrapped; ctx receives runtime
221
- # AND self is runtime, so both
222
- # `ctx.x` and bare `x` work
223
- #
224
- # Lambdas with arity 0 are preserved as-is (strict-arity safe). The
225
- # wrapping path uses Proc semantics, so extra args don't raise.
226
- def tools(*tools, &block)
227
- return super unless block
228
-
229
- super(&wrap_runtime_block(block))
230
- end
231
-
232
- def instructions(text = nil, **prompt_locals, &block)
233
- return super unless block
234
-
235
- super(text, **prompt_locals, &wrap_runtime_block(block))
236
- end
237
-
238
- def params(**params_kwargs, &block)
239
- return super unless block
240
-
241
- super(&wrap_runtime_block(block))
242
- end
243
-
244
- def headers(**headers_kwargs, &block)
245
- return super unless block
246
-
247
- super(&wrap_runtime_block(block))
248
- end
249
-
250
- def schema(value = nil, &block)
251
- return super unless block
252
-
253
- super(&wrap_runtime_block(block))
254
- end
255
-
256
- private
257
-
258
- def resolve_profile(model_id)
259
- return nil unless model_id
260
- return nil unless defined?(Smith::Models)
261
-
262
- Smith::Models.find_or_infer(model_id)
263
- end
264
-
265
- def inject_reserved_inputs(kwargs, profile)
266
- return kwargs unless profile
267
-
268
- reserved = {
269
- model_id: profile.model_id,
270
- provider: profile.provider,
271
- endpoint_mode: profile.endpoint_mode
272
- }
273
- # User-provided values win on key collision.
274
- reserved.merge(kwargs)
275
- end
276
-
277
- def nil_fill_declared_inputs(kwargs)
278
- inputs.each_with_object(kwargs.dup) do |name, result|
279
- result[name] = nil unless result.key?(name)
280
- end
281
- end
282
-
283
- def wrap_runtime_block(user_block)
284
- return user_block if user_block.arity.zero?
285
-
286
- proc do |*|
287
- runtime = self
288
- runtime.instance_exec(runtime, &user_block)
289
- end
290
- end
291
135
  end
292
136
  end
293
137
  end
138
+
139
+ require_relative "agent/dynamic_configuration"
140
+ require_relative "agent/fallback_configuration"
141
+ require_relative "agent/reserved_input_bridge"
142
+ require_relative "agent/chat_construction"
143
+
144
+ Smith::Agent.extend(Smith::Agent::DynamicConfiguration)
145
+ Smith::Agent.extend(Smith::Agent::FallbackConfiguration)
146
+ Smith::Agent.extend(Smith::Agent::ReservedInputBridge)
147
+ Smith::Agent.extend(Smith::Agent::ChatConstruction)
@@ -5,17 +5,41 @@ module Smith
5
5
  module ObservationMasking
6
6
  SYSTEM_ROLES = %i[system].push("system").freeze
7
7
 
8
- def self.apply(messages, strategy:)
8
+ def self.apply(messages, strategy:, seed_message_count: 0)
9
9
  return messages unless strategy
10
10
 
11
11
  window = strategy[:window]
12
12
  return messages unless window
13
13
 
14
- system_msgs, non_system = messages.partition do |message|
15
- SYSTEM_ROLES.include?(message[:role] || message["role"])
14
+ prefix_count = strategy[:preserve_seed] == true ? seed_message_count : 0
15
+ prefix = messages.first(prefix_count)
16
+ dynamic_messages = messages.drop(prefix_count)
17
+
18
+ prefix + system_messages(dynamic_messages) + recent_observations(dynamic_messages, window)
19
+ end
20
+
21
+ def self.system_message?(message)
22
+ SYSTEM_ROLES.include?(message[:role] || message["role"])
23
+ end
24
+
25
+ def self.system_messages(messages)
26
+ messages.select { |message| system_message?(message) }
27
+ end
28
+
29
+ def self.recent_observations(messages, window)
30
+ raise ArgumentError, "negative array size" if window.negative?
31
+ return [] if window.zero?
32
+
33
+ selected = []
34
+ messages.reverse_each do |message|
35
+ next if system_message?(message)
36
+
37
+ selected << message
38
+ break if selected.length == window
16
39
  end
17
- system_msgs + non_system.last(window)
40
+ selected.reverse
18
41
  end
42
+ private_class_method :system_message?, :system_messages, :recent_observations
19
43
  end
20
44
  end
21
45
  end
@@ -5,10 +5,11 @@ module Smith
5
5
  class Session
6
6
  attr_reader :messages
7
7
 
8
- def initialize(messages:, context_manager:, persisted_context:)
8
+ def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0)
9
9
  @messages = messages
10
10
  @context_manager = context_manager
11
11
  @persisted_context = persisted_context
12
+ @seed_message_count = seed_message_count
12
13
  end
13
14
 
14
15
  def inject_state!
@@ -26,7 +27,11 @@ module Smith
26
27
  return @messages unless @context_manager
27
28
 
28
29
  strategy = @context_manager.session_strategy
29
- ObservationMasking.apply(@messages, strategy: strategy)
30
+ ObservationMasking.apply(
31
+ @messages,
32
+ strategy: strategy,
33
+ seed_message_count: @seed_message_count
34
+ )
30
35
  end
31
36
 
32
37
  def prepare!