smith-agents 0.7.0 → 0.10.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +456 -0
  3. data/README.md +71 -1
  4. data/docs/CONFIGURATION.md +27 -6
  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 +43 -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 +128 -0
  13. data/lib/smith/agent/lifecycle.rb +8 -275
  14. data/lib/smith/agent/model_reference.rb +76 -0
  15. data/lib/smith/agent/provider_attempt.rb +34 -0
  16. data/lib/smith/agent/provider_call_timing.rb +74 -0
  17. data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
  18. data/lib/smith/agent/provider_completion.rb +148 -0
  19. data/lib/smith/agent/provider_failure_handling.rb +93 -0
  20. data/lib/smith/agent/provider_usage.rb +28 -0
  21. data/lib/smith/agent/reserved_input_bridge.rb +40 -0
  22. data/lib/smith/agent/usage_entry_recording.rb +66 -0
  23. data/lib/smith/agent/usage_traces.rb +49 -0
  24. data/lib/smith/agent/usage_tracking.rb +116 -0
  25. data/lib/smith/agent.rb +29 -197
  26. data/lib/smith/attribution.rb +115 -0
  27. data/lib/smith/context/observation_masking.rb +28 -4
  28. data/lib/smith/context/session.rb +7 -2
  29. data/lib/smith/diagnostic_text.rb +67 -0
  30. data/lib/smith/doctor/checks/configuration.rb +16 -3
  31. data/lib/smith/doctor/checks/models_registry.rb +75 -17
  32. data/lib/smith/errors.rb +34 -1
  33. data/lib/smith/event.rb +10 -2
  34. data/lib/smith/events/bus.rb +63 -5
  35. data/lib/smith/events/step_completed.rb +3 -0
  36. data/lib/smith/events/step_failed.rb +25 -0
  37. data/lib/smith/events/subscription.rb +8 -0
  38. data/lib/smith/models/ambiguous_profile_error.rb +9 -0
  39. data/lib/smith/models/collision_error.rb +9 -0
  40. data/lib/smith/models/inference.rb +14 -0
  41. data/lib/smith/models/normalizer.rb +11 -67
  42. data/lib/smith/models/provider_qualified_registry.rb +101 -0
  43. data/lib/smith/models/tool_routing.rb +140 -0
  44. data/lib/smith/models.rb +24 -78
  45. data/lib/smith/persisted_failure_invalid.rb +7 -0
  46. data/lib/smith/pricing.rb +138 -17
  47. data/lib/smith/pricing_configuration_error.rb +7 -0
  48. data/lib/smith/provider_permanent_failure.rb +16 -0
  49. data/lib/smith/tool/argument_container_reader.rb +97 -0
  50. data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
  51. data/lib/smith/tool/argument_snapshot.rb +19 -0
  52. data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
  53. data/lib/smith/tool/argument_snapshot_result.rb +15 -0
  54. data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
  55. data/lib/smith/tool/bounded_completion_context.rb +131 -0
  56. data/lib/smith/tool/bounded_completion_controls.rb +113 -0
  57. data/lib/smith/tool/bounded_completion_guard.rb +101 -0
  58. data/lib/smith/tool/bounded_completion_installation.rb +20 -0
  59. data/lib/smith/tool/bounded_completion_state.rb +57 -0
  60. data/lib/smith/tool/budget_enforcement.rb +15 -7
  61. data/lib/smith/tool/call_admission.rb +44 -0
  62. data/lib/smith/tool/call_allowance.rb +101 -28
  63. data/lib/smith/tool/call_allowance_counter.rb +43 -0
  64. data/lib/smith/tool/call_batch.rb +47 -0
  65. data/lib/smith/tool/call_budget.rb +51 -0
  66. data/lib/smith/tool/call_reservation.rb +42 -0
  67. data/lib/smith/tool/capture.rb +9 -1
  68. data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
  69. data/lib/smith/tool/chat_execution_context.rb +7 -88
  70. data/lib/smith/tool/execution_authority.rb +42 -0
  71. data/lib/smith/tool/execution_authorization.rb +17 -0
  72. data/lib/smith/tool/execution_batch.rb +100 -0
  73. data/lib/smith/tool/execution_batch_admission.rb +56 -0
  74. data/lib/smith/tool/execution_batch_builder.rb +122 -0
  75. data/lib/smith/tool/execution_batch_collection.rb +70 -0
  76. data/lib/smith/tool/execution_batch_invocations.rb +28 -0
  77. data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
  78. data/lib/smith/tool/execution_batch_registry.rb +99 -0
  79. data/lib/smith/tool/execution_batch_requests.rb +59 -0
  80. data/lib/smith/tool/execution_batch_source_call.rb +36 -0
  81. data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
  82. data/lib/smith/tool/execution_batch_sources.rb +32 -0
  83. data/lib/smith/tool/execution_batch_state.rb +110 -0
  84. data/lib/smith/tool/execution_dispatch.rb +78 -0
  85. data/lib/smith/tool/execution_failure_handling.rb +101 -0
  86. data/lib/smith/tool/execution_lifecycle.rb +28 -0
  87. data/lib/smith/tool/execution_tracker.rb +20 -0
  88. data/lib/smith/tool/fail_fast_completion.rb +24 -0
  89. data/lib/smith/tool/graceful_completion.rb +20 -0
  90. data/lib/smith/tool/invocation.rb +27 -0
  91. data/lib/smith/tool/invocation_request.rb +37 -0
  92. data/lib/smith/tool/invocation_sequence.rb +26 -0
  93. data/lib/smith/tool/legacy_call_allowance.rb +57 -0
  94. data/lib/smith/tool/scoped_context.rb +60 -8
  95. data/lib/smith/tool.rb +81 -13
  96. data/lib/smith/tool_execution_not_admitted.rb +7 -0
  97. data/lib/smith/tool_failure_notification_failed.rb +94 -0
  98. data/lib/smith/trace/logger.rb +1 -0
  99. data/lib/smith/trace/memory.rb +40 -4
  100. data/lib/smith/trace/open_telemetry.rb +47 -3
  101. data/lib/smith/trace.rb +24 -5
  102. data/lib/smith/version.rb +2 -2
  103. data/lib/smith/workflow/agent_result.rb +4 -3
  104. data/lib/smith/workflow/artifact_integration.rb +2 -1
  105. data/lib/smith/workflow/composite/effects.rb +64 -31
  106. data/lib/smith/workflow/deadline_enforcement.rb +20 -2
  107. data/lib/smith/workflow/deterministic_execution.rb +2 -1
  108. data/lib/smith/workflow/deterministic_step.rb +14 -1
  109. data/lib/smith/workflow/dsl.rb +25 -1
  110. data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
  111. data/lib/smith/workflow/event_integration.rb +48 -1
  112. data/lib/smith/workflow/execution.rb +10 -9
  113. data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
  114. data/lib/smith/workflow/failure_reconstructor.rb +79 -0
  115. data/lib/smith/workflow/failure_record.rb +139 -0
  116. data/lib/smith/workflow/failure_record_restore.rb +145 -0
  117. data/lib/smith/workflow/failure_record_text.rb +83 -0
  118. data/lib/smith/workflow/failure_record_validator.rb +113 -0
  119. data/lib/smith/workflow/fanout_execution.rb +1 -1
  120. data/lib/smith/workflow/guarded_step_execution.rb +39 -0
  121. data/lib/smith/workflow/guardrail_integration.rb +25 -0
  122. data/lib/smith/workflow/parallel.rb +14 -3
  123. data/lib/smith/workflow/parallel_execution.rb +6 -0
  124. data/lib/smith/workflow/persistence.rb +55 -20
  125. data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
  126. data/lib/smith/workflow/retry_execution.rb +5 -1
  127. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
  128. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  129. data/lib/smith/workflow/step_completion.rb +9 -0
  130. data/lib/smith/workflow/step_context.rb +46 -0
  131. data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
  132. data/lib/smith/workflow/transition.rb +9 -4
  133. data/lib/smith/workflow/usage_entry.rb +43 -5
  134. data/lib/smith/workflow.rb +43 -131
  135. data/lib/smith.rb +10 -2
  136. metadata +82 -11
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)
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ # Ambient execution attribution: an immutable, thread-local description of
5
+ # where execution currently is (run identity, transition, fan-out branch,
6
+ # optimizer round). Workflow execution scopes install it; observability
7
+ # consumers (traces, events, usage recording) read it, so emitted facts
8
+ # carry correlation without any consumer knowing about the workflow.
9
+ #
10
+ # Attribution values are opaque identifiers, never content: the trace
11
+ # content policy does not treat them as payload. `execution_key` defaults
12
+ # to the workflow's persistence key during persisted runs; hosts running
13
+ # non-persisted workflows can seed an outer scope explicitly:
14
+ #
15
+ # Smith::Attribution.with(execution_key: "host-run-42") { workflow.run! }
16
+ #
17
+ # Installation inside workflow internals is a plain assignment
18
+ # (Attribution.install) because restoration there is owned by the
19
+ # surrounding ThreadContextSnapshot, which tracks the attribution thread
20
+ # key alongside the other per-step thread state.
21
+ module Attribution
22
+ THREAD_KEY = :smith_attribution
23
+
24
+ Context = Data.define(:execution_key, :transition, :from, :to, :branch_key, :round, :workflow) do
25
+ # Overlay semantics: nil overrides are ignored so an inner scope can
26
+ # only add or replace attribution, never blank an outer value.
27
+ def merge(**overrides)
28
+ filtered = overrides.compact
29
+ return self if filtered.empty?
30
+
31
+ override(**filtered)
32
+ end
33
+
34
+ # Replacement semantics: sets the given fields verbatim, nil included,
35
+ # so a scope that owns a field can reset it (a step whose transition
36
+ # declares no `from` must not inherit an enclosing step's `from`).
37
+ # Built on to_h, not Data#with, which requires Ruby 3.3 while the gem
38
+ # supports 3.2.
39
+ def override(**fields)
40
+ self.class.new(**to_h, **fields)
41
+ end
42
+
43
+ def to_fields
44
+ {
45
+ execution_key: execution_key,
46
+ transition: transition,
47
+ from: from,
48
+ to: to,
49
+ branch_key: branch_key,
50
+ round: round,
51
+ workflow: workflow
52
+ }.compact
53
+ end
54
+ end
55
+
56
+ EMPTY = Context.new(
57
+ execution_key: nil, transition: nil, from: nil, to: nil, branch_key: nil, round: nil, workflow: nil
58
+ )
59
+
60
+ class << self
61
+ def current
62
+ Thread.current[THREAD_KEY]
63
+ end
64
+
65
+ def ambient
66
+ current || EMPTY
67
+ end
68
+
69
+ # The compacted attribution fields, for merging into emitted payloads.
70
+ def current_fields
71
+ context = current
72
+ context ? context.to_fields : {}
73
+ end
74
+
75
+ # Plain installation with no restoration: callers own restoration,
76
+ # either through ThreadContextSnapshot (workflow internals) or an
77
+ # enclosing #with / #carrying block.
78
+ def install(context)
79
+ Thread.current[THREAD_KEY] = context
80
+ end
81
+
82
+ # Host-facing scope: overlays the ambient attribution for the block.
83
+ def with(**overrides, &block)
84
+ raise ArgumentError, "block required" unless block
85
+
86
+ swap(ambient.merge(**overrides), &block)
87
+ end
88
+
89
+ # Cross-thread propagation: installs a context captured on another
90
+ # thread (or nil, clearing any stale value on a pooled thread) for the
91
+ # duration of the block.
92
+ def carrying(context, &block)
93
+ raise ArgumentError, "block required" unless block
94
+
95
+ swap(context, &block)
96
+ end
97
+
98
+ private
99
+
100
+ # Matches the gem's scoped thread-state idiom: install and restore run
101
+ # interrupt-deferred, the block itself runs interruptible.
102
+ def swap(context, &block)
103
+ previous = Thread.current[THREAD_KEY]
104
+ Thread.handle_interrupt(Object => :never) do
105
+ Thread.current[THREAD_KEY] = context
106
+ begin
107
+ Thread.handle_interrupt(Object => :immediate, &block)
108
+ ensure
109
+ Thread.current[THREAD_KEY] = previous
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -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
@@ -15,7 +15,12 @@ module Smith
15
15
  module_function
16
16
 
17
17
  def run(report)
18
- uncovered = uncovered_models
18
+ uncovered, ambiguous = classified_model_references
19
+ report_ambiguous_models(report, ambiguous)
20
+ report_model_coverage(report, uncovered)
21
+ end
22
+
23
+ def report_model_coverage(report, uncovered)
19
24
  if uncovered.empty?
20
25
  report.add(
21
26
  name: "models.coverage",
@@ -36,23 +41,65 @@ module Smith
36
41
  end
37
42
  end
38
43
 
44
+ # An unqualified agent model id registered under more than one
45
+ # provider cannot resolve to a single profile: chat construction
46
+ # fails closed with AmbiguousProfileError. Doctor must report that
47
+ # configuration instead of crashing on it.
48
+ def report_ambiguous_models(report, ambiguous)
49
+ return if ambiguous.empty?
50
+
51
+ descriptions = ambiguous.map { |reference| ambiguous_reference_description(reference) }
52
+ report.add(
53
+ name: "models.ambiguity",
54
+ status: :fail,
55
+ message: "#{ambiguous.size} agent model id(s) match profiles from multiple registered providers",
56
+ detail: "Ambiguous: #{descriptions.join("; ")}. Chat construction fails closed for " \
57
+ "these agents until each declares an explicit provider (for example " \
58
+ "model \"gpt-5\", provider: :openai) selecting exactly one registered profile."
59
+ )
60
+ end
61
+
62
+ def ambiguous_reference_description(reference)
63
+ "#{reference.model_id} (providers: #{registered_providers_for(reference).join(", ")})"
64
+ end
65
+
66
+ def registered_providers_for(reference)
67
+ Smith::Models.all
68
+ .select { |profile| profile.model_id == reference.model_id }
69
+ .map { |profile| profile.provider.to_s }
70
+ end
71
+
39
72
  # Walk Smith::Agent::Registry. For each agent, extract every static
40
73
  # model id Smith can know at boot: the primary `model "..."` value and
41
74
  # any static fallback models. Block-form primary models are skipped
42
75
  # because they resolve per-attempt, but their static fallbacks still
43
76
  # need coverage checks.
44
77
  # Check whether find_or_infer returns a custom (non-default)
45
- # Profile meaning either an explicit override or an inference
46
- # rule matched.
47
- def uncovered_models
48
- return [] unless defined?(Smith::Agent::Registry)
78
+ # Profile, meaning either an explicit override or an inference
79
+ # rule matched. Returns [uncovered, ambiguous] reference lists.
80
+ def classified_model_references
81
+ return [[], []] unless defined?(Smith::Agent::Registry)
49
82
 
50
- static_model_ids.uniq.reject { |model_id| covered_model?(model_id) }
83
+ uncovered = []
84
+ ambiguous = []
85
+ static_model_references.uniq(&:key).each do |reference|
86
+ case model_coverage(reference)
87
+ when :uncovered then uncovered << reference
88
+ when :ambiguous then ambiguous << reference
89
+ end
90
+ end
91
+ [uncovered, ambiguous]
92
+ end
93
+
94
+ def model_coverage(reference)
95
+ covered_model?(reference) ? :covered : :uncovered
96
+ rescue Smith::Models::AmbiguousProfileError
97
+ :ambiguous
51
98
  end
52
99
 
53
- def static_model_ids
54
- Smith::Agent::Registry.each.with_object([]) do |(_key, agent), ids|
55
- ids.concat(static_model_ids_for(agent)) if inspectable_agent?(agent)
100
+ def static_model_references
101
+ Smith::Agent::Registry.each.with_object([]) do |(_key, agent), references|
102
+ references.concat(static_model_references_for(agent)) if inspectable_agent?(agent)
56
103
  end
57
104
  end
58
105
 
@@ -60,16 +107,27 @@ module Smith
60
107
  agent.is_a?(Class) && agent.respond_to?(:chat_kwargs)
61
108
  end
62
109
 
63
- def static_model_ids_for(agent)
64
- [
65
- agent.chat_kwargs[:model],
66
- *(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil)
67
- ].compact
110
+ def static_model_references_for(agent)
111
+ primary = agent.chat_kwargs[:model]
112
+ references = Array(agent.respond_to?(:fallback_models) ? agent.fallback_models : nil).dup
113
+ if primary
114
+ references.unshift(
115
+ Smith::Agent::ModelReference.coerce(primary, provider: agent.chat_kwargs[:provider])
116
+ )
117
+ end
118
+ references
119
+ end
120
+
121
+ def covered_model?(reference)
122
+ Smith::Models.find(reference.model_id, provider: reference.provider) ||
123
+ inferred_profile_matches?(reference)
68
124
  end
69
125
 
70
- def covered_model?(model_id)
71
- Smith::Models.find(model_id) ||
72
- (defined?(Smith::Models::Inference) && Smith::Models::Inference.profile_for(model_id))
126
+ def inferred_profile_matches?(reference)
127
+ return false unless defined?(Smith::Models::Inference)
128
+
129
+ profile = Smith::Models::Inference.profile_for(reference.model_id)
130
+ profile && (reference.provider.nil? || profile.provider.to_sym == reference.provider)
73
131
  end
74
132
  end
75
133
  end