smith-agents 0.7.0 → 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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +226 -0
  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 +233 -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 +29 -197
  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 +15 -7
  53. data/lib/smith/tool/call_admission.rb +44 -0
  54. data/lib/smith/tool/call_allowance.rb +101 -28
  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/chat_execution_callbacks.rb +58 -0
  60. data/lib/smith/tool/chat_execution_context.rb +7 -88
  61. data/lib/smith/tool/execution_authority.rb +42 -0
  62. data/lib/smith/tool/execution_authorization.rb +17 -0
  63. data/lib/smith/tool/execution_batch.rb +100 -0
  64. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  65. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  66. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  67. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  68. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  69. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  70. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  71. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  72. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  73. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  74. data/lib/smith/tool/execution_batch_state.rb +110 -0
  75. data/lib/smith/tool/execution_dispatch.rb +78 -0
  76. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  77. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  78. data/lib/smith/tool/execution_tracker.rb +20 -0
  79. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  80. data/lib/smith/tool/graceful_completion.rb +20 -0
  81. data/lib/smith/tool/invocation.rb +27 -0
  82. data/lib/smith/tool/invocation_request.rb +37 -0
  83. data/lib/smith/tool/invocation_sequence.rb +26 -0
  84. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  85. data/lib/smith/tool/scoped_context.rb +60 -8
  86. data/lib/smith/tool.rb +71 -12
  87. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  88. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  89. data/lib/smith/version.rb +2 -2
  90. data/lib/smith/workflow/agent_result.rb +4 -3
  91. data/lib/smith/workflow/artifact_integration.rb +2 -1
  92. data/lib/smith/workflow/composite/effects.rb +3 -1
  93. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  94. data/lib/smith/workflow/execution.rb +2 -9
  95. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  96. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  97. data/lib/smith/workflow/failure_record.rb +139 -0
  98. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  99. data/lib/smith/workflow/failure_record_text.rb +83 -0
  100. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  101. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  102. data/lib/smith/workflow/parallel.rb +14 -3
  103. data/lib/smith/workflow/persistence.rb +22 -19
  104. data/lib/smith/workflow/retry_execution.rb +5 -1
  105. data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
  106. data/lib/smith/workflow/transition.rb +9 -4
  107. data/lib/smith/workflow/usage_entry.rb +12 -2
  108. data/lib/smith/workflow.rb +17 -130
  109. data/lib/smith.rb +3 -0
  110. metadata +78 -11
@@ -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
@@ -6,8 +6,14 @@ require_relative "tool"
6
6
 
7
7
  module Smith
8
8
  class Agent < RubyLLM::Agent
9
+ require_relative "agent/model_reference"
10
+
9
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
+
10
15
  private_constant :EXECUTION_IDENTITY_UNSET
16
+ private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
11
17
 
12
18
  # Reserved input names auto-injected by the normalizer into
13
19
  # runtime_context. User-side `inputs :name` calls cannot redeclare
@@ -23,8 +29,9 @@ module Smith
23
29
  subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
24
30
  subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
25
31
  subclass.instance_variable_set(:@data_volume, @data_volume)
26
- subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup)
32
+ subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
27
33
  subclass.instance_variable_set(:@model_block, @model_block)
34
+ subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
28
35
  subclass.instance_variable_set(:@execution_identity, nil)
29
36
  subclass.instance_variable_set(:@registered_name, nil)
30
37
  end
@@ -44,6 +51,17 @@ module Smith
44
51
  @budget_config = opts
45
52
  end
46
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
+
47
65
  def guardrails(klass = nil)
48
66
  return @guardrails_class if klass.nil?
49
67
 
@@ -62,15 +80,6 @@ module Smith
62
80
  @data_volume = value
63
81
  end
64
82
 
65
- def fallback_models(*models)
66
- return @fallback_models_list if models.empty?
67
-
68
- entries = models.flatten.compact.map(&:to_s)
69
- raise Smith::WorkflowError, "fallback_models entries must not be blank" if entries.any?(&:empty?)
70
-
71
- @fallback_models_list = entries.uniq
72
- end
73
-
74
83
  def register_as(name = nil, publish: true)
75
84
  return @registered_name if name.nil?
76
85
 
@@ -87,52 +96,6 @@ module Smith
87
96
  Registry.ensure_registered(name.to_sym, self)
88
97
  end
89
98
 
90
- # Extends RubyLLM::Agent.model with a block-form for context-driven
91
- # resolution at chat-construction time.
92
- #
93
- # Static form `model "gpt-5-mini"`:
94
- # Stores into @chat_kwargs[:model] via RubyLLM's existing path.
95
- # Model id is fixed at class-load time.
96
- #
97
- # Block form `model { |context| ... }`:
98
- # Stores the block as @model_block. Smith's lifecycle resolves it
99
- # at chat-construction time using the workflow's @context (Hash).
100
- # Return value must be a non-empty string; non-string / empty / nil
101
- # returns surface as Smith::AgentError at the resolution point
102
- # (see Smith::Agent::Lifecycle#build_model_chain).
103
- #
104
- # Mutually exclusive within a single declaration: passing both a
105
- # string id and a block raises ArgumentError. Redeclaring with the
106
- # other form clears the previous setting (static replaces block,
107
- # block replaces static).
108
- #
109
- # Composes with `fallback_models`: resolved primary, then declared
110
- # fallbacks, in order. Same path as static-form fallback.
111
- def model(model_id = nil, **options, &block)
112
- if block
113
- raise ArgumentError, "model can take a string id OR a block, not both" if model_id || !options.empty?
114
-
115
- @model_block = block
116
- # Clear any stale `@chat_kwargs[:model]` from a prior static-form
117
- # declaration. Smith's workflow lifecycle resolves block-form
118
- # correctly via `build_model_chain` (which checks @model_block
119
- # first), but RubyLLM's direct `chat()` and `with_rails_chat_record`
120
- # paths splat `**chat_kwargs` to the constructor; without this
121
- # delete, those paths would silently use the stale static id.
122
- # This is the only place Smith mutates a RubyLLM-owned ivar; the
123
- # mutation is well-scoped (only :model, only on block-form
124
- # declaration) and matches RubyLLM's own pattern of dup'ing
125
- # @chat_kwargs through its `inherited` hook.
126
- @chat_kwargs ||= {}
127
- @chat_kwargs.delete(:model)
128
- else
129
- @model_block = nil
130
- super
131
- end
132
- end
133
-
134
- attr_reader :model_block
135
-
136
99
  private
137
100
 
138
101
  def canonical_registration_name(name)
@@ -146,13 +109,6 @@ module Smith
146
109
 
147
110
  public
148
111
 
149
- # Whether this agent class has any model configured (static or block).
150
- # Smith::Workflow::Execution uses this as a precondition for invoking
151
- # the agent; agents declared without a model are skipped.
152
- def model_configured?
153
- !chat_kwargs[:model].nil? || !@model_block.nil?
154
- end
155
-
156
112
  # MERGING override: getter always returns user-declared ∪ reserved;
157
113
  # setter validates user names against reserved + stores only user
158
114
  # names. RubyLLM's bare `@input_names = names` (agent.rb:96) REPLACES;
@@ -176,140 +132,16 @@ module Smith
176
132
 
177
133
  @input_names = user_names.freeze
178
134
  end
179
-
180
- # Closes the `inputs` contract at the chat() boundary AND runs the
181
- # Smith::Models::Normalizer. Hook lives here (not in
182
- # Lifecycle#attempt_model) so direct callers that construct a chat
183
- # outside the workflow lifecycle are normalized too. Without this
184
- # placement, model-specific request translation would only fire for
185
- # workflow-driven calls.
186
- #
187
- # Single profile lookup: resolved once via Models.find_or_infer and
188
- # passed through both inject_reserved_inputs and Normalizer.apply!.
189
- def chat(**kwargs)
190
- # Resolve model from explicit kwarg first, then fall back to the
191
- # class-level chat_kwargs[:model] (set by `model "..."`). The
192
- # explicit kwarg path fires from Lifecycle#attempt_model (passes
193
- # the resolved primary or fallback model); the chat_kwargs path
194
- # fires from direct callers like `Agent.chat` with no args.
195
- model_id = kwargs[:model] || chat_kwargs[:model]
196
- profile = resolve_profile(model_id)
197
- kwargs = inject_reserved_inputs(kwargs, profile)
198
- kwargs = nil_fill_declared_inputs(kwargs)
199
-
200
- llm_chat = install_tool_execution_context(super)
201
- Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
202
- llm_chat
203
- end
204
-
205
- def create(**kwargs)
206
- install_tool_execution_context(super)
207
- end
208
-
209
- def create!(**kwargs)
210
- install_tool_execution_context(super)
211
- end
212
-
213
- def find(id, **kwargs)
214
- install_tool_execution_context(super)
215
- end
216
-
217
- # Normalizes the |ctx| DSL across RubyLLM's block-form attribute setters.
218
- #
219
- # RubyLLM evaluates these blocks via `runtime.instance_exec(&block)`,
220
- # which sets `self` to the runtime_context but passes NO positional
221
- # arguments, so `tools do |ctx| ctx.form_kind end` would silently
222
- # bind `ctx = nil` and crash on the first method call. Smith's `model`
223
- # block-form already uses `block.call(@context)` (an explicit Hash arg),
224
- # giving agent authors a uniform `|ctx|` mental model. These overrides
225
- # carry that convention through to RubyLLM's setters by wrapping any
226
- # block so `|ctx|` receives the runtime_context AND `self` is still the
227
- # runtime (preserving RubyLLM's bare-method-dispatch convention for
228
- # zero-arity blocks).
229
- #
230
- # Behavior matrix:
231
- # tools do ... end (arity 0): preserved as-is; bare method
232
- # calls dispatch to runtime via
233
- # instance_exec (RubyLLM idiom)
234
- # tools do |ctx| ... end (arity 1): wrapped; ctx receives runtime
235
- # AND self is runtime, so both
236
- # `ctx.x` and bare `x` work
237
- #
238
- # Lambdas with arity 0 are preserved as-is (strict-arity safe). The
239
- # wrapping path uses Proc semantics, so extra args don't raise.
240
- def tools(*tools, &block)
241
- return super unless block
242
-
243
- super(&wrap_runtime_block(block))
244
- end
245
-
246
- def instructions(text = nil, **prompt_locals, &block)
247
- return super unless block
248
-
249
- super(text, **prompt_locals, &wrap_runtime_block(block))
250
- end
251
-
252
- def params(**params_kwargs, &block)
253
- return super unless block
254
-
255
- super(&wrap_runtime_block(block))
256
- end
257
-
258
- def headers(**headers_kwargs, &block)
259
- return super unless block
260
-
261
- super(&wrap_runtime_block(block))
262
- end
263
-
264
- def schema(value = nil, &block)
265
- return super unless block
266
-
267
- super(&wrap_runtime_block(block))
268
- end
269
-
270
- private
271
-
272
- def install_tool_execution_context(chat_object)
273
- return unless chat_object
274
-
275
- llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
276
- Tool::ChatExecutionContext.install(llm_chat)
277
- chat_object
278
- end
279
-
280
- def resolve_profile(model_id)
281
- return nil unless model_id
282
- return nil unless defined?(Smith::Models)
283
-
284
- Smith::Models.find_or_infer(model_id)
285
- end
286
-
287
- def inject_reserved_inputs(kwargs, profile)
288
- return kwargs unless profile
289
-
290
- reserved = {
291
- model_id: profile.model_id,
292
- provider: profile.provider,
293
- endpoint_mode: profile.endpoint_mode
294
- }
295
- # User-provided values win on key collision.
296
- reserved.merge(kwargs)
297
- end
298
-
299
- def nil_fill_declared_inputs(kwargs)
300
- inputs.each_with_object(kwargs.dup) do |name, result|
301
- result[name] = nil unless result.key?(name)
302
- end
303
- end
304
-
305
- def wrap_runtime_block(user_block)
306
- return user_block if user_block.arity.zero?
307
-
308
- proc do |*|
309
- runtime = self
310
- runtime.instance_exec(runtime, &user_block)
311
- end
312
- end
313
135
  end
314
136
  end
315
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!
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class DiagnosticText
7
+ MAX_BYTES = 64 * 1024
8
+ TRUNCATION_MARKER = "...[truncated]"
9
+ STRING_BYTESIZE = String.instance_method(:bytesize)
10
+ STRING_BYTESLICE = String.instance_method(:byteslice)
11
+ MODULE_MATCH = Module.instance_method(:===)
12
+ private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH
13
+
14
+ extend Dry::Initializer
15
+
16
+ param :value
17
+ option :max_bytes, default: proc { MAX_BYTES }
18
+
19
+ def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
20
+
21
+ def call
22
+ validate_limit!
23
+ text = utf8_prefix
24
+ return text.freeze if text.bytesize <= max_bytes
25
+
26
+ truncate(text)
27
+ end
28
+
29
+ private
30
+
31
+ def validate_limit!
32
+ return if max_bytes.is_a?(Integer) && max_bytes >= TRUNCATION_MARKER.bytesize
33
+
34
+ raise ArgumentError, "diagnostic text limit is too small"
35
+ end
36
+
37
+ def utf8_prefix
38
+ source = string_value
39
+ limit = (max_bytes * 4) + TRUNCATION_MARKER.bytesize
40
+ prefix = STRING_BYTESLICE.bind_call(source, 0, limit)
41
+ prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
42
+ rescue EncodingError
43
+ "unavailable diagnostic text"
44
+ end
45
+
46
+ def string_value
47
+ return value if MODULE_MATCH.bind_call(String, value)
48
+
49
+ String(value)
50
+ rescue StandardError
51
+ "unavailable diagnostic text"
52
+ end
53
+
54
+ def truncate(text)
55
+ budget = max_bytes - TRUNCATION_MARKER.bytesize
56
+ prefix = +""
57
+
58
+ text.each_char do |character|
59
+ break if prefix.bytesize + character.bytesize > budget
60
+
61
+ prefix << character
62
+ end
63
+
64
+ (prefix << TRUNCATION_MARKER).freeze
65
+ end
66
+ end
67
+ end
@@ -43,11 +43,24 @@ module Smith
43
43
 
44
44
  def self.check_pricing(report)
45
45
  configured = ::Smith.config.pricing.is_a?(Hash) && !::Smith.config.pricing.empty?
46
+ unless configured
47
+ report.add(
48
+ name: "config.pricing",
49
+ status: :warn,
50
+ message: "No pricing configured",
51
+ detail: "RunResult.total_cost will be 0.0"
52
+ )
53
+ return
54
+ end
55
+
56
+ ::Smith::Pricing.validate_catalog!
57
+ report.add(name: "config.pricing", status: :pass, message: "Pricing configured")
58
+ rescue ::Smith::PricingConfigurationError => e
46
59
  report.add(
47
60
  name: "config.pricing",
48
- status: configured ? :pass : :warn,
49
- message: configured ? "Pricing configured" : "No pricing configured",
50
- detail: configured ? nil : "RunResult.total_cost will be 0.0"
61
+ status: :fail,
62
+ message: "Pricing catalog invalid",
63
+ detail: e.message
51
64
  )
52
65
  end
53
66
  end