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
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
def smith_load_environment
|
|
4
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
5
|
+
require "smith"
|
|
6
|
+
require "smith/doctor"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
namespace :smith do
|
|
10
|
+
desc "Verify Smith integration (offline)"
|
|
11
|
+
task :doctor do
|
|
12
|
+
smith_load_environment
|
|
13
|
+
report = Smith::Doctor.run
|
|
14
|
+
exit report.exit_code unless report.passed?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
namespace :doctor do
|
|
18
|
+
desc "Verify Smith integration with live provider call"
|
|
19
|
+
task :live do
|
|
20
|
+
smith_load_environment
|
|
21
|
+
report = Smith::Doctor.run(live: true)
|
|
22
|
+
exit report.exit_code unless report.passed?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc "Verify Smith workflow durability"
|
|
26
|
+
task :durability do
|
|
27
|
+
smith_load_environment
|
|
28
|
+
report = Smith::Doctor.run(durability: true)
|
|
29
|
+
exit report.exit_code unless report.passed?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "Scaffold Smith configuration files"
|
|
34
|
+
task :install do
|
|
35
|
+
smith_load_environment
|
|
36
|
+
Smith::Doctor::Installer.run
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module BudgetEnforcement
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def charge_tool_call!
|
|
9
|
+
allowance = self.class.current_tool_call_allowance
|
|
10
|
+
ledger = self.class.current_ledger
|
|
11
|
+
workflow_active = ledger&.limits&.key?(:tool_calls)
|
|
12
|
+
|
|
13
|
+
check_agent_tool_calls!(allowance)
|
|
14
|
+
commit_tool_call_charges!(ledger, allowance, workflow_active)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check_agent_tool_calls!(allowance)
|
|
18
|
+
return unless allowance
|
|
19
|
+
|
|
20
|
+
raise BudgetExceeded, "agent tool_calls budget exceeded" if allowance[:remaining] <= 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def commit_tool_call_charges!(ledger, allowance, workflow_active)
|
|
24
|
+
if workflow_active
|
|
25
|
+
ledger.reserve!(:tool_calls, 1)
|
|
26
|
+
ledger.reconcile!(:tool_calls, 1, 1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
allowance[:remaining] -= 1 if allowance
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
class CapabilityBuilder
|
|
6
|
+
def initialize
|
|
7
|
+
@capabilities = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def sensitivity(value) = @capabilities[:sensitivity] = value
|
|
11
|
+
def privilege(value) = @capabilities[:privilege] = value
|
|
12
|
+
def network(value) = @capabilities[:network] = value
|
|
13
|
+
def approval(value) = @capabilities[:approval] = value
|
|
14
|
+
def data_volume(value) = @capabilities[:data_volume] = value
|
|
15
|
+
def to_h = @capabilities
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module Capture
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def capture_result_if_configured(kwargs, result)
|
|
9
|
+
block = self.class.capture_result
|
|
10
|
+
return unless block
|
|
11
|
+
|
|
12
|
+
collector = self.class.current_tool_result_collector
|
|
13
|
+
return unless collector
|
|
14
|
+
|
|
15
|
+
captured = block.call(kwargs, result)
|
|
16
|
+
collector.call({ tool: name.to_s, captured: captured }) if captured
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
Smith.config.logger&.warn("[Smith] capture_result failed for #{name}: #{e.message}")
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool
|
|
5
|
+
# Compatibility spec for a Tool class. Built by Tool.compatible_with(...)
|
|
6
|
+
# and consulted by Smith::Models::Normalizer when deciding whether
|
|
7
|
+
# to route, drop, or pass through a tool.
|
|
8
|
+
#
|
|
9
|
+
# Spec shape (frozen Hash):
|
|
10
|
+
# providers: Set[Symbol]?, # allowlist; nil = all allowed
|
|
11
|
+
# endpoints: Hash[Symbol => Set], # per-provider endpoint constraints
|
|
12
|
+
# except: Hash[Symbol => Set]? # exception list (overrides allow)
|
|
13
|
+
#
|
|
14
|
+
# Tools that don't declare compatible_with are universally compatible
|
|
15
|
+
# — Compatibility.allows?(nil, profile) returns true.
|
|
16
|
+
module Compatibility
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Parses the DSL invocation:
|
|
20
|
+
# compatible_with :anthropic
|
|
21
|
+
# compatible_with :anthropic, :gemini, openai: :responses
|
|
22
|
+
# compatible_with except: { openai: :chat_completions }
|
|
23
|
+
def parse(positional, except:, **provider_endpoints)
|
|
24
|
+
providers_arg = positional + provider_endpoints.keys
|
|
25
|
+
providers = if providers_arg.empty?
|
|
26
|
+
nil
|
|
27
|
+
else
|
|
28
|
+
providers_arg.map(&:to_sym).to_set
|
|
29
|
+
end
|
|
30
|
+
endpoints = provider_endpoints.transform_values { |v| Array(v).map(&:to_sym).to_set }
|
|
31
|
+
except_set = except&.transform_values { |v| Array(v).map(&:to_sym).to_set }
|
|
32
|
+
|
|
33
|
+
{
|
|
34
|
+
providers: providers,
|
|
35
|
+
endpoints: endpoints,
|
|
36
|
+
except: except_set
|
|
37
|
+
}.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns true if the (provider, endpoint) combination is allowed
|
|
41
|
+
# by spec. `effective_endpoint` defaults to profile.endpoint_mode
|
|
42
|
+
# but callers (e.g., Smith::Models::Normalizer) can override when
|
|
43
|
+
# user policy downgrades the endpoint — e.g., a profile with
|
|
44
|
+
# tools_with_thinking_route: :responses still has its tools checked
|
|
45
|
+
# against :chat_completions when Smith.config.openai_api_mode is
|
|
46
|
+
# :off (no routing).
|
|
47
|
+
#
|
|
48
|
+
# spec == nil => universally compatible (no compatible_with declared).
|
|
49
|
+
def allows?(spec, profile, effective_endpoint: nil)
|
|
50
|
+
return true if spec.nil?
|
|
51
|
+
|
|
52
|
+
provider = profile.provider
|
|
53
|
+
endpoint = effective_endpoint || profile.endpoint_mode
|
|
54
|
+
|
|
55
|
+
# exception list: explicit deny wins
|
|
56
|
+
if (excluded = spec[:except]&.[](provider)) && excluded.include?(endpoint)
|
|
57
|
+
return false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# allowlist by provider (nil = all allowed)
|
|
61
|
+
return false if spec[:providers] && !spec[:providers].include?(provider)
|
|
62
|
+
|
|
63
|
+
# endpoint constraint when present for the matched provider
|
|
64
|
+
if (allowed_endpoints = spec[:endpoints][provider])
|
|
65
|
+
return allowed_endpoints.include?(endpoint)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Tool < RubyLLM::Tool
|
|
5
|
+
module Policy
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def check_privilege!(kwargs)
|
|
9
|
+
privilege = self.class.capabilities&.dig(:privilege)
|
|
10
|
+
return if privilege.nil? || privilege == :none
|
|
11
|
+
|
|
12
|
+
context = kwargs[:context] || {}
|
|
13
|
+
enforce_privilege!(privilege, context)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def enforce_privilege!(privilege, context)
|
|
17
|
+
require_authenticated!(context) if %i[authenticated elevated].include?(privilege)
|
|
18
|
+
require_elevated!(context) if privilege == :elevated
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def require_authenticated!(context)
|
|
22
|
+
raise ToolPolicyDenied, "privilege requires context[:user]" unless context[:user]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def require_elevated!(context)
|
|
26
|
+
return if context[:role] == :elevated
|
|
27
|
+
|
|
28
|
+
raise ToolPolicyDenied, "privilege :elevated requires context[:role] == :elevated"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_authorization!(kwargs)
|
|
32
|
+
authorizer = self.class.authorize
|
|
33
|
+
return unless authorizer
|
|
34
|
+
|
|
35
|
+
context = kwargs[:context]
|
|
36
|
+
raise ToolPolicyDenied unless authorizer.call(context)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/smith/tool.rb
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_llm"
|
|
4
|
+
|
|
5
|
+
require_relative "tool/capability_builder"
|
|
6
|
+
require_relative "tool/policy"
|
|
7
|
+
require_relative "tool/budget_enforcement"
|
|
8
|
+
require_relative "tool/capture"
|
|
9
|
+
require_relative "tool/compatibility"
|
|
10
|
+
|
|
11
|
+
module Smith
|
|
12
|
+
class Tool < RubyLLM::Tool
|
|
13
|
+
include Policy
|
|
14
|
+
include BudgetEnforcement
|
|
15
|
+
include Capture
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Tool subclasses inherit the parent's compatible_with spec by
|
|
19
|
+
# reference (the spec is a frozen Hash; immutability makes shared
|
|
20
|
+
# references safe). Subclasses can override by calling
|
|
21
|
+
# `compatible_with` again — assigns a NEW frozen Hash to its own
|
|
22
|
+
# @compatible_with_spec, leaving the parent untouched.
|
|
23
|
+
def inherited(subclass)
|
|
24
|
+
super
|
|
25
|
+
subclass.instance_variable_set(:@compatible_with_spec, @compatible_with_spec)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Declarative compatibility DSL. Examples:
|
|
29
|
+
# compatible_with :anthropic, :gemini
|
|
30
|
+
# compatible_with :anthropic, :gemini, openai: :responses
|
|
31
|
+
# compatible_with except: { openai: :chat_completions }
|
|
32
|
+
#
|
|
33
|
+
# Tools that NEVER declare compatible_with are universally compatible.
|
|
34
|
+
# Consumed by Smith::Models::Normalizer.drop_incompatible_tools when
|
|
35
|
+
# the resolved model rejects the (tools + thinking) combo and no
|
|
36
|
+
# routing fallback (e.g., openai_api_mode :auto) is available.
|
|
37
|
+
def compatible_with(*providers, except: nil, **provider_endpoints)
|
|
38
|
+
@compatible_with_spec = Compatibility.parse(providers, except: except, **provider_endpoints)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
attr_reader :compatible_with_spec
|
|
42
|
+
|
|
43
|
+
def current_guardrails
|
|
44
|
+
Thread.current[:smith_tool_guardrails]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def current_guardrails=(value)
|
|
48
|
+
Thread.current[:smith_tool_guardrails] = value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def current_deadline
|
|
52
|
+
Thread.current[:smith_tool_deadline]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def current_deadline=(value)
|
|
56
|
+
Thread.current[:smith_tool_deadline] = value
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def current_ledger
|
|
60
|
+
Thread.current[:smith_tool_ledger]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def current_ledger=(value)
|
|
64
|
+
Thread.current[:smith_tool_ledger] = value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current_tool_call_allowance
|
|
68
|
+
Thread.current[:smith_tool_call_allowance]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def current_tool_call_allowance=(value)
|
|
72
|
+
Thread.current[:smith_tool_call_allowance] = value
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def current_tool_result_collector
|
|
76
|
+
Thread.current[:smith_tool_result_collector]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def current_tool_result_collector=(value)
|
|
80
|
+
Thread.current[:smith_tool_result_collector] = value
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def category(value = nil)
|
|
84
|
+
return @category if value.nil?
|
|
85
|
+
|
|
86
|
+
@category = value
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def capabilities(&)
|
|
90
|
+
return @capabilities unless block_given?
|
|
91
|
+
|
|
92
|
+
builder = CapabilityBuilder.new
|
|
93
|
+
builder.instance_eval(&)
|
|
94
|
+
@capabilities = builder.to_h
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def authorize(&block)
|
|
98
|
+
return @authorize unless block_given?
|
|
99
|
+
|
|
100
|
+
@authorize = block
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def before_execute(&block)
|
|
104
|
+
return @before_execute unless block_given?
|
|
105
|
+
|
|
106
|
+
@before_execute = block
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def capture_result(&block)
|
|
110
|
+
return @capture_result unless block_given?
|
|
111
|
+
|
|
112
|
+
@capture_result = block
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def execute(**kwargs)
|
|
117
|
+
run_before_execute_hook!(kwargs)
|
|
118
|
+
check_tool_deadline!
|
|
119
|
+
check_privilege!(kwargs)
|
|
120
|
+
check_authorization!(kwargs)
|
|
121
|
+
run_tool_guardrails!(kwargs)
|
|
122
|
+
check_tool_deadline!
|
|
123
|
+
charge_tool_call!
|
|
124
|
+
|
|
125
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
126
|
+
result = perform(**kwargs)
|
|
127
|
+
duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
|
128
|
+
|
|
129
|
+
emit_tool_trace(kwargs, result, duration)
|
|
130
|
+
capture_result_if_configured(kwargs, result)
|
|
131
|
+
result
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
private
|
|
135
|
+
|
|
136
|
+
def run_before_execute_hook!(kwargs)
|
|
137
|
+
hook = self.class.before_execute
|
|
138
|
+
return unless hook
|
|
139
|
+
|
|
140
|
+
hook.call(self, kwargs)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def run_tool_guardrails!(kwargs)
|
|
144
|
+
guardrails_classes = self.class.current_guardrails
|
|
145
|
+
return unless guardrails_classes
|
|
146
|
+
|
|
147
|
+
Array(guardrails_classes).each do |guardrails_class|
|
|
148
|
+
Guardrails::Runner.run_tool(guardrails_class, name.to_sym, kwargs)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def emit_tool_trace(kwargs, result, duration)
|
|
153
|
+
Smith::Trace.record(
|
|
154
|
+
type: :tool_call,
|
|
155
|
+
data: { tool: name, args: kwargs, result: result, duration: duration },
|
|
156
|
+
sensitivity: self.class.capabilities&.dig(:sensitivity) || :low
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def check_tool_deadline!
|
|
161
|
+
deadline = self.class.current_deadline
|
|
162
|
+
return unless deadline
|
|
163
|
+
|
|
164
|
+
raise DeadlineExceeded, "wall_clock deadline exceeded during tool execution" if Time.now.utc >= deadline
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def perform(**kwargs)
|
|
168
|
+
raise NotImplementedError, "#{self.class} must implement #perform"
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Tools
|
|
5
|
+
class Think < Smith::Tool
|
|
6
|
+
description "Think through your approach between steps. " \
|
|
7
|
+
"Plan what to do next, evaluate progress, and identify gaps."
|
|
8
|
+
category :computation
|
|
9
|
+
|
|
10
|
+
# Compatible with Anthropic (extended thinking is native), Gemini
|
|
11
|
+
# (thinking is the default request shape), and OpenAI BUT ONLY on
|
|
12
|
+
# /v1/responses — chat-completions rejects function tools combined
|
|
13
|
+
# with reasoning_effort for the gpt-5 family. The normalizer uses
|
|
14
|
+
# this spec when deciding whether to drop Think on a model whose
|
|
15
|
+
# profile rejects (tools + thinking) AND no routing fallback exists.
|
|
16
|
+
compatible_with :anthropic, :gemini, openai: :responses
|
|
17
|
+
|
|
18
|
+
param :thought, type: :string, required: true
|
|
19
|
+
|
|
20
|
+
def perform(thought:) # rubocop:disable Lint/UnusedMethodArgument
|
|
21
|
+
{ acknowledged: true }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Tools
|
|
5
|
+
class UrlFetcher < Smith::Tool
|
|
6
|
+
description "Fetch the content of a specific URL"
|
|
7
|
+
category :data_access
|
|
8
|
+
|
|
9
|
+
param :url, type: :string, required: true
|
|
10
|
+
|
|
11
|
+
def perform(url:)
|
|
12
|
+
raise NotImplementedError, "#{self.class} requires a host-app implementation"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Tools
|
|
5
|
+
class WebSearch < Smith::Tool
|
|
6
|
+
description "Search the web for current information on a topic"
|
|
7
|
+
category :data_access
|
|
8
|
+
|
|
9
|
+
param :query, type: :string, required: true
|
|
10
|
+
param :max_results, type: :integer, required: false
|
|
11
|
+
|
|
12
|
+
def perform(query:, max_results: 5)
|
|
13
|
+
raise NotImplementedError, "#{self.class} requires a host-app implementation"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/smith/tools.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Trace
|
|
5
|
+
class Logger
|
|
6
|
+
CONFIG_MAP = {
|
|
7
|
+
transition: :trace_transitions,
|
|
8
|
+
tool_call: :trace_tool_calls,
|
|
9
|
+
token_usage: :trace_token_usage,
|
|
10
|
+
cost: :trace_cost,
|
|
11
|
+
normalizer_decision: :trace_normalizer
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
15
|
+
|
|
16
|
+
def record(type:, data:)
|
|
17
|
+
return unless type_enabled?(type)
|
|
18
|
+
|
|
19
|
+
logger = Smith.config.logger
|
|
20
|
+
return unless logger
|
|
21
|
+
|
|
22
|
+
logger.info("[Smith::Trace] #{type}: #{filter_content(data).inspect}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def type_enabled?(type)
|
|
28
|
+
config_key = CONFIG_MAP[type]
|
|
29
|
+
return true unless config_key
|
|
30
|
+
|
|
31
|
+
Smith.config.send(config_key) != false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def filter_content(data)
|
|
35
|
+
case Smith.config.trace_content
|
|
36
|
+
when true
|
|
37
|
+
data
|
|
38
|
+
when :redacted
|
|
39
|
+
data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
|
|
40
|
+
else
|
|
41
|
+
data.except(*CONTENT_KEYS)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Trace
|
|
5
|
+
class Memory
|
|
6
|
+
CONFIG_MAP = {
|
|
7
|
+
transition: :trace_transitions,
|
|
8
|
+
tool_call: :trace_tool_calls,
|
|
9
|
+
token_usage: :trace_token_usage,
|
|
10
|
+
cost: :trace_cost,
|
|
11
|
+
normalizer_decision: :trace_normalizer
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :traces
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@traces = []
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def record(type:, data:)
|
|
23
|
+
return unless type_enabled?(type)
|
|
24
|
+
|
|
25
|
+
@traces << { type: type, data: filter_content(data) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def clear!
|
|
29
|
+
@traces = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def type_enabled?(type)
|
|
35
|
+
config_key = CONFIG_MAP[type]
|
|
36
|
+
return true unless config_key
|
|
37
|
+
|
|
38
|
+
Smith.config.send(config_key) != false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def filter_content(data)
|
|
42
|
+
case Smith.config.trace_content
|
|
43
|
+
when true
|
|
44
|
+
data
|
|
45
|
+
when :redacted
|
|
46
|
+
data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
|
|
47
|
+
else
|
|
48
|
+
data.except(*CONTENT_KEYS)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Trace
|
|
5
|
+
class OpenTelemetry
|
|
6
|
+
CONFIG_MAP = {
|
|
7
|
+
transition: :trace_transitions,
|
|
8
|
+
tool_call: :trace_tool_calls,
|
|
9
|
+
token_usage: :trace_token_usage,
|
|
10
|
+
cost: :trace_cost
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
CONTENT_KEYS = %i[content prompt response args result].freeze
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
require "opentelemetry-api"
|
|
17
|
+
@tracer = ::OpenTelemetry.tracer_provider.tracer("smith", Smith::VERSION)
|
|
18
|
+
rescue LoadError
|
|
19
|
+
@tracer = nil
|
|
20
|
+
Smith.config.logger&.warn(
|
|
21
|
+
"Smith::Trace::OpenTelemetry requires the opentelemetry-api gem. " \
|
|
22
|
+
"Add it to your Gemfile to enable OpenTelemetry tracing."
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record(type:, data:)
|
|
27
|
+
return unless @tracer
|
|
28
|
+
return unless type_enabled?(type)
|
|
29
|
+
|
|
30
|
+
filtered = filter_content(data)
|
|
31
|
+
@tracer.in_span("smith.#{type}") do |span|
|
|
32
|
+
filtered.each { |key, value| span.set_attribute("smith.#{key}", value.to_s) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def type_enabled?(type)
|
|
39
|
+
config_key = CONFIG_MAP[type]
|
|
40
|
+
return true unless config_key
|
|
41
|
+
|
|
42
|
+
Smith.config.send(config_key) != false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def filter_content(data)
|
|
46
|
+
case Smith.config.trace_content
|
|
47
|
+
when true
|
|
48
|
+
data
|
|
49
|
+
when :redacted
|
|
50
|
+
data.transform_values { |v| v.is_a?(String) ? "[REDACTED]" : v }
|
|
51
|
+
else
|
|
52
|
+
data.except(*CONTENT_KEYS)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|