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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
data/lib/smith/agent.rb
ADDED
|
@@ -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,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
|
data/lib/smith/budget.rb
ADDED
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
|