smith-agents 0.4.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 (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. metadata +258 -0
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ruby_llm"
4
+
5
+ module Smith
6
+ class Agent < RubyLLM::Agent
7
+ # Reserved input names auto-injected by the normalizer into
8
+ # runtime_context. User-side `inputs :name` calls cannot redeclare
9
+ # these names; the override raises Smith::AgentError if they try.
10
+ # The getter merges user-declared inputs WITH reserved so subclasses
11
+ # don't lose reserved names when declaring their own.
12
+ RESERVED_INPUT_NAMES = %i[model_id provider endpoint_mode].freeze
13
+
14
+ class << self
15
+ def inherited(subclass)
16
+ super
17
+ subclass.instance_variable_set(:@budget_config, @budget_config)
18
+ subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
19
+ subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
20
+ subclass.instance_variable_set(:@data_volume, @data_volume)
21
+ subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup)
22
+ subclass.instance_variable_set(:@model_block, @model_block)
23
+ subclass.instance_variable_set(:@registered_name, nil)
24
+ end
25
+
26
+ def budget(**opts)
27
+ return @budget_config if opts.empty?
28
+
29
+ @budget_config = opts
30
+ end
31
+
32
+ def guardrails(klass = nil)
33
+ return @guardrails_class if klass.nil?
34
+
35
+ @guardrails_class = klass
36
+ end
37
+
38
+ def output_schema(klass = nil)
39
+ return @output_schema_class if klass.nil?
40
+
41
+ @output_schema_class = klass
42
+ end
43
+
44
+ def data_volume(value = nil)
45
+ return @data_volume if value.nil?
46
+
47
+ @data_volume = value
48
+ end
49
+
50
+ def fallback_models(*models)
51
+ return @fallback_models_list if models.empty?
52
+
53
+ entries = models.flatten.compact.map(&:to_s)
54
+ raise Smith::WorkflowError, "fallback_models entries must not be blank" if entries.any?(&:empty?)
55
+
56
+ @fallback_models_list = entries.uniq
57
+ end
58
+
59
+ def register_as(name = nil)
60
+ return @registered_name if name.nil?
61
+
62
+ @registered_name = name
63
+ Registry.ensure_registered(name.to_sym, self)
64
+ end
65
+
66
+ # Extends RubyLLM::Agent.model with a block-form for context-driven
67
+ # resolution at chat-construction time.
68
+ #
69
+ # Static form `model "gpt-5-mini"`:
70
+ # Stores into @chat_kwargs[:model] via RubyLLM's existing path.
71
+ # Model id is fixed at class-load time.
72
+ #
73
+ # Block form `model { |context| ... }`:
74
+ # Stores the block as @model_block. Smith's lifecycle resolves it
75
+ # at chat-construction time using the workflow's @context (Hash).
76
+ # Return value must be a non-empty string; non-string / empty / nil
77
+ # returns surface as Smith::AgentError at the resolution point
78
+ # (see Smith::Agent::Lifecycle#build_model_chain).
79
+ #
80
+ # Mutually exclusive within a single declaration: passing both a
81
+ # string id and a block raises ArgumentError. Redeclaring with the
82
+ # other form clears the previous setting (static replaces block,
83
+ # block replaces static).
84
+ #
85
+ # Composes with `fallback_models`: resolved primary, then declared
86
+ # fallbacks, in order. Same path as static-form fallback.
87
+ def model(model_id = nil, **options, &block)
88
+ if block
89
+ raise ArgumentError, "model can take a string id OR a block, not both" if model_id || !options.empty?
90
+
91
+ @model_block = block
92
+ # Clear any stale `@chat_kwargs[:model]` from a prior static-form
93
+ # declaration. Smith's workflow lifecycle resolves block-form
94
+ # correctly via `build_model_chain` (which checks @model_block
95
+ # first), but RubyLLM's direct `chat()` and `with_rails_chat_record`
96
+ # paths splat `**chat_kwargs` to the constructor; without this
97
+ # delete, those paths would silently use the stale static id.
98
+ # This is the only place Smith mutates a RubyLLM-owned ivar; the
99
+ # mutation is well-scoped (only :model, only on block-form
100
+ # declaration) and matches RubyLLM's own pattern of dup'ing
101
+ # @chat_kwargs through its `inherited` hook.
102
+ @chat_kwargs ||= {}
103
+ @chat_kwargs.delete(:model)
104
+ else
105
+ @model_block = nil
106
+ super
107
+ end
108
+ end
109
+
110
+ attr_reader :model_block
111
+
112
+ # Whether this agent class has any model configured (static or block).
113
+ # Smith::Workflow::Execution uses this as a precondition for invoking
114
+ # the agent; agents declared without a model are skipped.
115
+ def model_configured?
116
+ !chat_kwargs[:model].nil? || !@model_block.nil?
117
+ end
118
+
119
+ # MERGING override: getter always returns user-declared ∪ reserved;
120
+ # setter validates user names against reserved + stores only user
121
+ # names. RubyLLM's bare `@input_names = names` (agent.rb:96) REPLACES;
122
+ # this override prevents subclasses from losing reserved names when
123
+ # they declare their own inputs.
124
+ def inputs(*names)
125
+ if names.empty?
126
+ user = @input_names || []
127
+ return (user + RESERVED_INPUT_NAMES).uniq.freeze
128
+ end
129
+
130
+ user_names = names.flatten.map(&:to_sym)
131
+ collisions = user_names & RESERVED_INPUT_NAMES
132
+ if collisions.any?
133
+ raise Smith::AgentError,
134
+ "agent input names #{collisions.inspect} are reserved by Smith. " \
135
+ "Reserved names #{RESERVED_INPUT_NAMES.inspect} are auto-injected by " \
136
+ "Smith::Models::Normalizer into runtime_context. " \
137
+ "Rename your inputs to avoid the collision."
138
+ end
139
+
140
+ @input_names = user_names.freeze
141
+ end
142
+
143
+ # Closes the `inputs` contract at the chat() boundary AND runs the
144
+ # Smith::Models::Normalizer. Hook lives here (not in
145
+ # Lifecycle#attempt_model) so direct callers like hadithi-xl's
146
+ # InvokeCleaner.chat (which constructs a chat outside the workflow
147
+ # lifecycle) are normalized too. Without this placement, Cleaner's
148
+ # Opus 4.7 adaptive thinking translation would only fire for
149
+ # workflow-driven calls.
150
+ #
151
+ # Single profile lookup: resolved once via Models.find_or_infer and
152
+ # passed through both inject_reserved_inputs and Normalizer.apply!.
153
+ def chat(**kwargs)
154
+ # Resolve model from explicit kwarg first, then fall back to the
155
+ # class-level chat_kwargs[:model] (set by `model "..."`). The
156
+ # explicit kwarg path fires from Lifecycle#attempt_model (passes
157
+ # the resolved primary or fallback model); the chat_kwargs path
158
+ # fires from direct callers like `Agent.chat` with no args.
159
+ model_id = kwargs[:model] || chat_kwargs[:model]
160
+ profile = resolve_profile(model_id)
161
+ kwargs = inject_reserved_inputs(kwargs, profile)
162
+ kwargs = nil_fill_declared_inputs(kwargs)
163
+
164
+ llm_chat = super
165
+ Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
166
+ llm_chat
167
+ end
168
+
169
+ # Normalizes the |ctx| DSL across RubyLLM's block-form attribute setters.
170
+ #
171
+ # RubyLLM evaluates these blocks via `runtime.instance_exec(&block)`,
172
+ # which sets `self` to the runtime_context but passes NO positional
173
+ # arguments, so `tools do |ctx| ctx.form_kind end` would silently
174
+ # bind `ctx = nil` and crash on the first method call. Smith's `model`
175
+ # block-form already uses `block.call(@context)` (an explicit Hash arg),
176
+ # giving agent authors a uniform `|ctx|` mental model. These overrides
177
+ # carry that convention through to RubyLLM's setters by wrapping any
178
+ # block so `|ctx|` receives the runtime_context AND `self` is still the
179
+ # runtime (preserving RubyLLM's bare-method-dispatch convention for
180
+ # zero-arity blocks).
181
+ #
182
+ # Behavior matrix:
183
+ # tools do ... end (arity 0): preserved as-is; bare method
184
+ # calls dispatch to runtime via
185
+ # instance_exec (RubyLLM idiom)
186
+ # tools do |ctx| ... end (arity 1): wrapped; ctx receives runtime
187
+ # AND self is runtime, so both
188
+ # `ctx.x` and bare `x` work
189
+ #
190
+ # Lambdas with arity 0 are preserved as-is (strict-arity safe). The
191
+ # wrapping path uses Proc semantics, so extra args don't raise.
192
+ def tools(*tools, &block)
193
+ return super unless block
194
+
195
+ super(&wrap_runtime_block(block))
196
+ end
197
+
198
+ def instructions(text = nil, **prompt_locals, &block)
199
+ return super unless block
200
+
201
+ super(text, **prompt_locals, &wrap_runtime_block(block))
202
+ end
203
+
204
+ def params(**params_kwargs, &block)
205
+ return super unless block
206
+
207
+ super(&wrap_runtime_block(block))
208
+ end
209
+
210
+ def headers(**headers_kwargs, &block)
211
+ return super unless block
212
+
213
+ super(&wrap_runtime_block(block))
214
+ end
215
+
216
+ def schema(value = nil, &block)
217
+ return super unless block
218
+
219
+ super(&wrap_runtime_block(block))
220
+ end
221
+
222
+ private
223
+
224
+ def resolve_profile(model_id)
225
+ return nil unless model_id
226
+ return nil unless defined?(Smith::Models)
227
+
228
+ Smith::Models.find_or_infer(model_id)
229
+ end
230
+
231
+ def inject_reserved_inputs(kwargs, profile)
232
+ return kwargs unless profile
233
+
234
+ reserved = {
235
+ model_id: profile.model_id,
236
+ provider: profile.provider,
237
+ endpoint_mode: profile.endpoint_mode
238
+ }
239
+ # User-provided values win on key collision.
240
+ reserved.merge(kwargs)
241
+ end
242
+
243
+ def nil_fill_declared_inputs(kwargs)
244
+ inputs.each_with_object(kwargs.dup) do |name, result|
245
+ result[name] = nil unless result.key?(name)
246
+ end
247
+ end
248
+
249
+ def wrap_runtime_block(user_block)
250
+ return user_block if user_block.arity.zero?
251
+
252
+ proc do |*|
253
+ runtime = self
254
+ runtime.instance_exec(runtime, &user_block)
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "json"
5
+
6
+ module Smith
7
+ module Artifacts
8
+ class File
9
+ def initialize(dir:, namespace: nil)
10
+ @dir = dir
11
+ @namespace = namespace
12
+ end
13
+
14
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
15
+ ref = generate_ref
16
+ meta = { content_type: content_type }
17
+ meta[:execution_namespace] = execution_namespace if execution_namespace
18
+ ::File.write(::File.join(@dir, ref), data)
19
+ ::File.write(::File.join(@dir, "#{ref}.meta"), JSON.generate(meta))
20
+ ref
21
+ end
22
+
23
+ def fetch(ref)
24
+ path = ::File.join(@dir, ref)
25
+ ::File.exist?(path) ? ::File.read(path) : nil
26
+ end
27
+
28
+ def expired(retention: nil, execution_namespace: nil)
29
+ return [] unless retention
30
+
31
+ cutoff = Time.now.utc - retention
32
+ Dir.glob(::File.join(@dir, "*")).reject { |f| f.end_with?(".meta") }.filter_map do |path|
33
+ ref = ::File.basename(path)
34
+ next unless ::File.mtime(path).utc < cutoff
35
+ next if execution_namespace && !matches_execution_namespace?(ref, execution_namespace)
36
+
37
+ ref
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def generate_ref
44
+ raw = SecureRandom.uuid
45
+ @namespace ? "#{@namespace}:#{raw}" : raw
46
+ end
47
+
48
+ def matches_execution_namespace?(ref, execution_namespace)
49
+ meta_path = ::File.join(@dir, "#{ref}.meta")
50
+ return false unless ::File.exist?(meta_path)
51
+
52
+ meta = JSON.parse(::File.read(meta_path), symbolize_names: true)
53
+ meta[:execution_namespace] == execution_namespace
54
+ rescue JSON::ParserError
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Smith
6
+ module Artifacts
7
+ class Memory
8
+ attr_reader :namespace
9
+
10
+ def initialize(namespace: nil)
11
+ @namespace = namespace
12
+ @store = {}
13
+ @metadata = {}
14
+ end
15
+
16
+ def store(data, content_type: "application/octet-stream", execution_namespace: nil)
17
+ enforce_tenant_isolation!
18
+ ref = generate_ref(data)
19
+ @store[ref] = data
20
+ @metadata[ref] ||= { content_type: content_type, stored_at: Time.now.utc, execution_namespaces: [] }
21
+ tag_execution_namespace(ref, execution_namespace)
22
+ ref
23
+ end
24
+
25
+ def fetch(ref)
26
+ enforce_tenant_isolation!
27
+ return nil unless owns_ref?(ref)
28
+
29
+ @store[ref]
30
+ end
31
+
32
+ def expired(retention: nil, execution_namespace: nil)
33
+ effective_retention = retention || Smith.config.artifact_retention
34
+ return [] unless effective_retention
35
+
36
+ cutoff = Time.now.utc - effective_retention
37
+ @metadata.select { |ref, meta| expired_match?(ref, meta, cutoff, execution_namespace) }.keys
38
+ end
39
+
40
+ private
41
+
42
+ def tag_execution_namespace(ref, execution_namespace)
43
+ return unless execution_namespace
44
+
45
+ namespaces = @metadata[ref][:execution_namespaces]
46
+ namespaces << execution_namespace unless namespaces.include?(execution_namespace)
47
+ end
48
+
49
+ def expired_match?(ref, meta, cutoff, execution_namespace)
50
+ owns_ref?(ref) &&
51
+ meta[:stored_at] < cutoff &&
52
+ (execution_namespace.nil? || meta[:execution_namespaces]&.include?(execution_namespace))
53
+ end
54
+
55
+ def generate_ref(data)
56
+ content_hash = Digest::SHA256.hexdigest(data.to_s)
57
+ @namespace ? "#{@namespace}:#{content_hash}" : content_hash
58
+ end
59
+
60
+ def owns_ref?(ref)
61
+ if @namespace
62
+ ref.start_with?("#{@namespace}:")
63
+ else
64
+ !ref.include?(":")
65
+ end
66
+ end
67
+
68
+ def enforce_tenant_isolation!
69
+ return unless Smith.config.artifact_tenant_isolation
70
+
71
+ raise Smith::Error, "artifact_tenant_isolation requires a namespace" unless @namespace
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Artifacts
5
+ class ScopedStore
6
+ def initialize(backend:, namespace:)
7
+ @backend = backend
8
+ @namespace = namespace
9
+ end
10
+
11
+ def store(data, content_type: "application/octet-stream")
12
+ inner_ref = @backend.store(data, content_type: content_type, execution_namespace: @namespace)
13
+ "#{@namespace}:#{inner_ref}"
14
+ end
15
+
16
+ def fetch(ref)
17
+ return nil unless ref.start_with?("#{@namespace}:")
18
+
19
+ inner_ref = ref.delete_prefix("#{@namespace}:")
20
+ @backend.fetch(inner_ref)
21
+ end
22
+
23
+ def expired(retention: nil)
24
+ @backend.expired(retention: retention, execution_namespace: @namespace)
25
+ .map { |ref| "#{@namespace}:#{ref}" }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Artifacts; end
5
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Budget
5
+ class Ledger
6
+ attr_reader :limits, :consumed
7
+
8
+ def initialize(limits: {}, consumed: {})
9
+ @mutex = Mutex.new
10
+ @limits = limits
11
+ @consumed = Hash.new(0).merge(consumed)
12
+ @reserved = Hash.new(0)
13
+ end
14
+
15
+ def reserve!(key, amount)
16
+ @mutex.synchronize do
17
+ committed = @consumed[key] + @reserved[key]
18
+ raise BudgetExceeded if committed + amount > @limits[key]
19
+
20
+ @reserved[key] += amount
21
+ end
22
+ end
23
+
24
+ def reconcile!(key, reserved_amount, actual_amount)
25
+ @mutex.synchronize do
26
+ @reserved[key] = [0, @reserved[key] - reserved_amount].max
27
+ @consumed[key] += actual_amount
28
+ end
29
+ end
30
+
31
+ def release!(key, amount)
32
+ @mutex.synchronize do
33
+ @reserved[key] = [0, @reserved[key] - amount].max
34
+ end
35
+ end
36
+
37
+ def remaining(key)
38
+ @mutex.synchronize { [@limits[key] - @consumed[key] - @reserved[key], 0].max }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ module Budget; end
5
+ end
data/lib/smith/cli.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "doctor"
5
+
6
+ module Smith
7
+ class CLI
8
+ def initialize(argv)
9
+ @argv = argv.dup
10
+ end
11
+
12
+ def run
13
+ command = @argv.shift || "doctor"
14
+ case command
15
+ when "doctor" then run_doctor
16
+ when "install" then run_install
17
+ when "version" then run_version
18
+ when "--help", "-h" then run_help
19
+ else
20
+ warn "Unknown command: #{command}"
21
+ warn usage
22
+ 1
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def run_doctor
29
+ options = parse_doctor_options
30
+ report = Smith::Doctor.run(
31
+ live: options[:live],
32
+ durability: options[:durability],
33
+ profile: options[:profile] || :auto
34
+ )
35
+ report.exit_code
36
+ end
37
+
38
+ def parse_doctor_options
39
+ options = {}
40
+ OptionParser.new do |opts|
41
+ opts.banner = "Usage: smith doctor [options]"
42
+ opts.on("--live", "Include live provider verification") { options[:live] = true }
43
+ opts.on("--durability", "Include workflow durability checks") { options[:durability] = true }
44
+ opts.on("--profile PROFILE", "Verification profile (auto, plain, rails_persistence, durable)") do |p|
45
+ options[:profile] = p.to_sym
46
+ end
47
+ end.parse!(@argv)
48
+ options
49
+ end
50
+
51
+ def run_install
52
+ Smith::Doctor::Installer.run
53
+ 0
54
+ end
55
+
56
+ def run_version
57
+ puts "smith #{Smith::VERSION}"
58
+ 0
59
+ end
60
+
61
+ def run_help
62
+ puts usage
63
+ 0
64
+ end
65
+
66
+ def usage
67
+ <<~TEXT
68
+ Usage: smith <command> [options]
69
+
70
+ Commands:
71
+ doctor Verify Smith integration (default)
72
+ install Scaffold Smith configuration files
73
+ version Show Smith version
74
+
75
+ Doctor options:
76
+ --live Include live provider verification
77
+ --durability Include workflow durability checks
78
+ --profile P Verification profile (auto, plain, rails_persistence, durable)
79
+ TEXT
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Context
5
+ module ObservationMasking
6
+ SYSTEM_ROLES = %i[system].push("system").freeze
7
+
8
+ def self.apply(messages, strategy:)
9
+ return messages unless strategy
10
+
11
+ window = strategy[:window]
12
+ return messages unless window
13
+
14
+ system_msgs, non_system = messages.partition { |m| SYSTEM_ROLES.include?(m[:role]) }
15
+ system_msgs + non_system.last(window)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Context
5
+ class Session
6
+ attr_reader :messages
7
+
8
+ def initialize(messages:, context_manager:, persisted_context:)
9
+ @messages = messages
10
+ @context_manager = context_manager
11
+ @persisted_context = persisted_context
12
+ end
13
+
14
+ def inject_state!
15
+ return unless @context_manager
16
+
17
+ formatter = @context_manager.inject_state
18
+ return unless formatter
19
+
20
+ @messages.replace(
21
+ StateInjection.inject(@messages, formatter: formatter, persisted: @persisted_context)
22
+ )
23
+ end
24
+
25
+ def masked_view
26
+ return @messages unless @context_manager
27
+
28
+ strategy = @context_manager.session_strategy
29
+ ObservationMasking.apply(@messages, strategy: strategy)
30
+ end
31
+
32
+ def prepare!
33
+ inject_state!
34
+ masked_view
35
+ end
36
+
37
+ def append(message)
38
+ @messages << message
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Context
5
+ module StateInjection
6
+ MARKER = "[smith:injected-state]"
7
+
8
+ def self.inject(messages, formatter:, persisted:)
9
+ content = "#{MARKER}\n#{formatter.call(persisted)}"
10
+
11
+ existing_index = messages.index do |message|
12
+ message_content = message[:content]
13
+ message_content.is_a?(String) && message_content.start_with?(MARKER)
14
+ end
15
+
16
+ if existing_index
17
+ messages.dup.tap { |msgs| msgs[existing_index] = { role: :system, content: content } }
18
+ else
19
+ messages + [{ role: :system, content: content }]
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end