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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +456 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +27 -6
- data/docs/PERSISTENCE.md +27 -1
- data/docs/TOOLS_AND_GUARDRAILS.md +233 -2
- data/lib/smith/agent/chat_construction.rb +112 -0
- data/lib/smith/agent/completion.rb +46 -0
- data/lib/smith/agent/completion_usage_recording.rb +43 -0
- data/lib/smith/agent/dynamic_configuration.rb +69 -0
- data/lib/smith/agent/fallback_configuration.rb +26 -0
- data/lib/smith/agent/invocation_preparation.rb +128 -0
- data/lib/smith/agent/lifecycle.rb +8 -275
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +34 -0
- data/lib/smith/agent/provider_call_timing.rb +74 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +148 -0
- data/lib/smith/agent/provider_failure_handling.rb +93 -0
- data/lib/smith/agent/provider_usage.rb +28 -0
- data/lib/smith/agent/reserved_input_bridge.rb +40 -0
- data/lib/smith/agent/usage_entry_recording.rb +66 -0
- data/lib/smith/agent/usage_traces.rb +49 -0
- data/lib/smith/agent/usage_tracking.rb +116 -0
- data/lib/smith/agent.rb +29 -197
- data/lib/smith/attribution.rb +115 -0
- data/lib/smith/context/observation_masking.rb +28 -4
- data/lib/smith/context/session.rb +7 -2
- data/lib/smith/diagnostic_text.rb +67 -0
- data/lib/smith/doctor/checks/configuration.rb +16 -3
- data/lib/smith/doctor/checks/models_registry.rb +75 -17
- data/lib/smith/errors.rb +34 -1
- data/lib/smith/event.rb +10 -2
- data/lib/smith/events/bus.rb +63 -5
- data/lib/smith/events/step_completed.rb +3 -0
- data/lib/smith/events/step_failed.rb +25 -0
- data/lib/smith/events/subscription.rb +8 -0
- data/lib/smith/models/ambiguous_profile_error.rb +9 -0
- data/lib/smith/models/collision_error.rb +9 -0
- data/lib/smith/models/inference.rb +14 -0
- data/lib/smith/models/normalizer.rb +11 -67
- data/lib/smith/models/provider_qualified_registry.rb +101 -0
- data/lib/smith/models/tool_routing.rb +140 -0
- data/lib/smith/models.rb +24 -78
- data/lib/smith/persisted_failure_invalid.rb +7 -0
- data/lib/smith/pricing.rb +138 -17
- data/lib/smith/pricing_configuration_error.rb +7 -0
- data/lib/smith/provider_permanent_failure.rb +16 -0
- data/lib/smith/tool/argument_container_reader.rb +97 -0
- data/lib/smith/tool/argument_scalar_snapshot.rb +99 -0
- data/lib/smith/tool/argument_snapshot.rb +19 -0
- data/lib/smith/tool/argument_snapshot_accounting.rb +69 -0
- data/lib/smith/tool/argument_snapshot_result.rb +15 -0
- data/lib/smith/tool/argument_snapshot_traversal.rb +119 -0
- data/lib/smith/tool/bounded_completion_context.rb +131 -0
- data/lib/smith/tool/bounded_completion_controls.rb +113 -0
- data/lib/smith/tool/bounded_completion_guard.rb +101 -0
- data/lib/smith/tool/bounded_completion_installation.rb +20 -0
- data/lib/smith/tool/bounded_completion_state.rb +57 -0
- data/lib/smith/tool/budget_enforcement.rb +15 -7
- data/lib/smith/tool/call_admission.rb +44 -0
- data/lib/smith/tool/call_allowance.rb +101 -28
- data/lib/smith/tool/call_allowance_counter.rb +43 -0
- data/lib/smith/tool/call_batch.rb +47 -0
- data/lib/smith/tool/call_budget.rb +51 -0
- data/lib/smith/tool/call_reservation.rb +42 -0
- data/lib/smith/tool/capture.rb +9 -1
- data/lib/smith/tool/chat_execution_callbacks.rb +58 -0
- data/lib/smith/tool/chat_execution_context.rb +7 -88
- data/lib/smith/tool/execution_authority.rb +42 -0
- data/lib/smith/tool/execution_authorization.rb +17 -0
- data/lib/smith/tool/execution_batch.rb +100 -0
- data/lib/smith/tool/execution_batch_admission.rb +56 -0
- data/lib/smith/tool/execution_batch_builder.rb +122 -0
- data/lib/smith/tool/execution_batch_collection.rb +70 -0
- data/lib/smith/tool/execution_batch_invocations.rb +28 -0
- data/lib/smith/tool/execution_batch_lifecycle.rb +91 -0
- data/lib/smith/tool/execution_batch_registry.rb +99 -0
- data/lib/smith/tool/execution_batch_requests.rb +59 -0
- data/lib/smith/tool/execution_batch_source_call.rb +36 -0
- data/lib/smith/tool/execution_batch_source_metadata.rb +107 -0
- data/lib/smith/tool/execution_batch_sources.rb +32 -0
- data/lib/smith/tool/execution_batch_state.rb +110 -0
- data/lib/smith/tool/execution_dispatch.rb +78 -0
- data/lib/smith/tool/execution_failure_handling.rb +101 -0
- data/lib/smith/tool/execution_lifecycle.rb +28 -0
- data/lib/smith/tool/execution_tracker.rb +20 -0
- data/lib/smith/tool/fail_fast_completion.rb +24 -0
- data/lib/smith/tool/graceful_completion.rb +20 -0
- data/lib/smith/tool/invocation.rb +27 -0
- data/lib/smith/tool/invocation_request.rb +37 -0
- data/lib/smith/tool/invocation_sequence.rb +26 -0
- data/lib/smith/tool/legacy_call_allowance.rb +57 -0
- data/lib/smith/tool/scoped_context.rb +60 -8
- data/lib/smith/tool.rb +81 -13
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- data/lib/smith/trace/logger.rb +1 -0
- data/lib/smith/trace/memory.rb +40 -4
- data/lib/smith/trace/open_telemetry.rb +47 -3
- data/lib/smith/trace.rb +24 -5
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/agent_result.rb +4 -3
- data/lib/smith/workflow/artifact_integration.rb +2 -1
- data/lib/smith/workflow/composite/effects.rb +64 -31
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/deterministic_execution.rb +2 -1
- data/lib/smith/workflow/deterministic_step.rb +14 -1
- data/lib/smith/workflow/dsl.rb +25 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +3 -1
- data/lib/smith/workflow/event_integration.rb +48 -1
- data/lib/smith/workflow/execution.rb +10 -9
- data/lib/smith/workflow/failure_detail_snapshot.rb +33 -0
- data/lib/smith/workflow/failure_reconstructor.rb +79 -0
- data/lib/smith/workflow/failure_record.rb +139 -0
- data/lib/smith/workflow/failure_record_restore.rb +145 -0
- data/lib/smith/workflow/failure_record_text.rb +83 -0
- data/lib/smith/workflow/failure_record_validator.rb +113 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/guardrail_integration.rb +25 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/parallel_execution.rb +6 -0
- data/lib/smith/workflow/persistence.rb +55 -20
- data/lib/smith/workflow/prepared_branch_execution.rb +13 -4
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +9 -1
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +9 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +6 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +43 -5
- data/lib/smith/workflow.rb +43 -131
- data/lib/smith.rb +10 -2
- metadata +82 -11
data/lib/smith/errors.rb
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "error"
|
|
4
|
+
require_relative "persisted_failure_invalid"
|
|
5
|
+
require_relative "pricing_configuration_error"
|
|
6
|
+
require_relative "provider_permanent_failure"
|
|
7
|
+
require_relative "tool_capture_failed"
|
|
8
|
+
require_relative "tool_execution_not_admitted"
|
|
9
|
+
require_relative "tool_failure_notification_failed"
|
|
4
10
|
|
|
5
11
|
module Smith
|
|
6
12
|
# Classification surface for host retry policies. Smith owns the
|
|
7
13
|
# answer to "should the workflow attempt be retried?" so consumers
|
|
8
14
|
# don't reimplement the case statement in every Execution / Job.
|
|
9
15
|
module Errors
|
|
16
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
17
|
+
private_constant :MODULE_MATCH
|
|
18
|
+
|
|
10
19
|
# Returns true when the host should retry the workflow attempt.
|
|
11
20
|
# AgentError + DeadlineExceeded are always retryable.
|
|
12
21
|
# DeterministicStepFailure + ToolGuardrailFailed honor their
|
|
@@ -16,7 +25,7 @@ module Smith
|
|
|
16
25
|
return false if error.nil?
|
|
17
26
|
|
|
18
27
|
composite_failure = defined?(Smith::Workflow::Composite::BranchFailure) &&
|
|
19
|
-
|
|
28
|
+
MODULE_MATCH.bind_call(Smith::Workflow::Composite::BranchFailure, error)
|
|
20
29
|
return false if composite_failure
|
|
21
30
|
|
|
22
31
|
case error
|
|
@@ -29,6 +38,27 @@ module Smith
|
|
|
29
38
|
end
|
|
30
39
|
end
|
|
31
40
|
|
|
41
|
+
def self.retry_forbidden?(error)
|
|
42
|
+
return false if error.nil?
|
|
43
|
+
|
|
44
|
+
retry_forbidden_classes.any? { |error_class| MODULE_MATCH.bind_call(error_class, error) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.retry_forbidden_class?(error_class)
|
|
48
|
+
retry_forbidden_classes.any? { |forbidden| error_class <= forbidden }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.retry_forbidden_classes
|
|
52
|
+
@retry_forbidden_classes ||= [
|
|
53
|
+
Smith::ToolCaptureFailed,
|
|
54
|
+
Smith::ToolOutcomeUncertain,
|
|
55
|
+
Smith::ToolExecutionNotAdmitted,
|
|
56
|
+
Smith::ToolFailureNotificationFailed,
|
|
57
|
+
Smith::BoundedCompletionError,
|
|
58
|
+
Smith::PersistedFailureInvalid
|
|
59
|
+
].freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
32
62
|
# Always-retryable error classes for explicit ActiveJob retry_on
|
|
33
63
|
# allow-lists. Excludes the retryable-bearing families because
|
|
34
64
|
# their retryability is per-raise, not per-class.
|
|
@@ -52,6 +82,9 @@ module Smith
|
|
|
52
82
|
end
|
|
53
83
|
|
|
54
84
|
class ToolPolicyDenied < Error; end
|
|
85
|
+
class ToolDispatchRejected < Error; end
|
|
86
|
+
class BoundedCompletionError < Error; end
|
|
87
|
+
class ToolOutcomeUncertain < Error; end
|
|
55
88
|
class AgentError < Error; end
|
|
56
89
|
|
|
57
90
|
class BlankAgentOutputError < AgentError
|
data/lib/smith/event.rb
CHANGED
|
@@ -3,9 +3,17 @@
|
|
|
3
3
|
require "dry-struct"
|
|
4
4
|
require "securerandom"
|
|
5
5
|
|
|
6
|
+
require_relative "attribution"
|
|
7
|
+
|
|
6
8
|
module Smith
|
|
7
9
|
class Event < Dry::Struct
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
# Both ids default to the ambient Smith::Attribution execution key, so
|
|
11
|
+
# events emitted under one persisted run (or one host-seeded
|
|
12
|
+
# Attribution scope) share one identity a host can group by. Outside
|
|
13
|
+
# any scope, each event falls back to its own random UUID, so
|
|
14
|
+
# non-persisted, unseeded runs have no shared event identity. Callers
|
|
15
|
+
# may always pass explicit values.
|
|
16
|
+
attribute(:execution_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
|
|
17
|
+
attribute(:trace_id, Types::String.default { Smith::Attribution.ambient.execution_key || SecureRandom.uuid })
|
|
10
18
|
end
|
|
11
19
|
end
|
data/lib/smith/events/bus.rb
CHANGED
|
@@ -18,19 +18,43 @@ module Smith
|
|
|
18
18
|
end
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
+
REGISTRY_MUTEX = Mutex.new
|
|
22
|
+
private_constant :REGISTRY_MUTEX
|
|
23
|
+
|
|
21
24
|
class << self
|
|
25
|
+
# Registration-ordered snapshot of the live subscriptions. Cancelled
|
|
26
|
+
# subscriptions are detached from the registry, so this reflects only
|
|
27
|
+
# what will actually receive events.
|
|
22
28
|
def subscriptions
|
|
23
|
-
|
|
29
|
+
snapshot = REGISTRY_MUTEX.synchronize { registry.values.flatten }
|
|
30
|
+
snapshot.sort_by!(&:sequence_number)
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def on(event_class, **opts, &block)
|
|
27
34
|
sub = Subscription.new(event_class, handler: block, predicate: opts[:if])
|
|
28
|
-
|
|
35
|
+
REGISTRY_MUTEX.synchronize do
|
|
36
|
+
@sequence = (@sequence || 0) + 1
|
|
37
|
+
sub.sequence_number = @sequence
|
|
38
|
+
(registry[event_class] ||= []) << sub
|
|
39
|
+
end
|
|
29
40
|
sub
|
|
30
41
|
end
|
|
31
42
|
|
|
43
|
+
# Removes a subscription from the registry. Called by
|
|
44
|
+
# Subscription#cancel; safe to call more than once.
|
|
45
|
+
def detach(subscription)
|
|
46
|
+
REGISTRY_MUTEX.synchronize do
|
|
47
|
+
bucket = registry[subscription.event_class]
|
|
48
|
+
next unless bucket
|
|
49
|
+
|
|
50
|
+
bucket.delete(subscription)
|
|
51
|
+
registry.delete(subscription.event_class) if bucket.empty?
|
|
52
|
+
end
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
32
56
|
def emit(event)
|
|
33
|
-
|
|
57
|
+
matching_subscriptions(event).each { |sub| dispatch_to(sub, event) }
|
|
34
58
|
end
|
|
35
59
|
|
|
36
60
|
def within
|
|
@@ -41,14 +65,48 @@ module Smith
|
|
|
41
65
|
end
|
|
42
66
|
|
|
43
67
|
def reset!
|
|
44
|
-
|
|
68
|
+
REGISTRY_MUTEX.synchronize do
|
|
69
|
+
@registry = {}
|
|
70
|
+
@sequence = 0
|
|
71
|
+
end
|
|
45
72
|
end
|
|
46
73
|
|
|
47
74
|
private
|
|
48
75
|
|
|
76
|
+
def registry
|
|
77
|
+
@registry ||= {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Subscriptions live in per-class buckets so one emit touches only the
|
|
81
|
+
# buckets for the event's ancestors instead of scanning every
|
|
82
|
+
# subscription; `is_a?` dispatch semantics are preserved exactly
|
|
83
|
+
# because a subscription matches iff its registered class or module is
|
|
84
|
+
# among the event class's ancestors. The merged candidates are ordered
|
|
85
|
+
# by registration sequence, keeping subscription-order dispatch.
|
|
86
|
+
# Handlers run outside the registry lock so a handler may subscribe or
|
|
87
|
+
# cancel without deadlocking.
|
|
88
|
+
def matching_subscriptions(event)
|
|
89
|
+
# singleton_class.ancestors, not class.ancestors: it additionally
|
|
90
|
+
# covers modules mixed into the event instance via extend, which
|
|
91
|
+
# `is_a?` matched before the bucketed registry existed. Immediates
|
|
92
|
+
# (Integer, Symbol, Float) have no singleton class; they also cannot
|
|
93
|
+
# be extended, so their class ancestors are the complete `is_a?` set.
|
|
94
|
+
ancestors = begin
|
|
95
|
+
event.singleton_class.ancestors
|
|
96
|
+
rescue TypeError
|
|
97
|
+
event.class.ancestors
|
|
98
|
+
end
|
|
99
|
+
candidates = REGISTRY_MUTEX.synchronize do
|
|
100
|
+
ancestors.each_with_object([]) do |ancestor, found|
|
|
101
|
+
bucket = registry[ancestor]
|
|
102
|
+
found.concat(bucket) if bucket
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
candidates.sort_by!(&:sequence_number)
|
|
106
|
+
end
|
|
107
|
+
|
|
49
108
|
def dispatch_to(sub, event)
|
|
50
109
|
return if sub.cancelled?
|
|
51
|
-
return unless event.is_a?(sub.event_class)
|
|
52
110
|
return if sub.predicate && !sub.predicate.call(event)
|
|
53
111
|
|
|
54
112
|
sub.handler.call(event)
|
|
@@ -6,6 +6,9 @@ module Smith
|
|
|
6
6
|
attribute :transition, Types::Strict::Symbol
|
|
7
7
|
attribute :from, Types::Strict::Symbol.optional
|
|
8
8
|
attribute :to, Types::Strict::Symbol
|
|
9
|
+
# The emitting workflow's class name; distinguishes nested-child steps
|
|
10
|
+
# from parent steps under the shared root execution identity.
|
|
11
|
+
attribute :workflow, Types::Strict::String.optional.default(nil)
|
|
9
12
|
end
|
|
10
13
|
end
|
|
11
14
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Events
|
|
5
|
+
# Emitted from both step-failure paths (step-body failure and an
|
|
6
|
+
# unresolved transition routed to :fail), closing the success-only
|
|
7
|
+
# observation gap for failures that reach step handling. Terminal errors
|
|
8
|
+
# raised outside it (an unresolved transition with no :fail transition,
|
|
9
|
+
# transition-budget exhaustion, origin validation) still re-raise
|
|
10
|
+
# without a StepFailed. Carries bounded classification only, never raw
|
|
11
|
+
# error messages; `error_family` uses FailureRecord's taxonomy
|
|
12
|
+
# ("agent_error", "deadline_exceeded", ..., "other") and `retryable` is
|
|
13
|
+
# nil when the error does not declare retryability.
|
|
14
|
+
class StepFailed < Smith::Event
|
|
15
|
+
attribute :transition, Types::Strict::Symbol
|
|
16
|
+
attribute :from, Types::Strict::Symbol.optional
|
|
17
|
+
attribute :to, Types::Strict::Symbol.optional
|
|
18
|
+
attribute :error_class, Types::Strict::String
|
|
19
|
+
attribute :error_family, Types::Strict::String
|
|
20
|
+
attribute :retryable, Types::Strict::Bool.optional
|
|
21
|
+
# See StepCompleted#workflow.
|
|
22
|
+
attribute :workflow, Types::Strict::String.optional.default(nil)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -4,6 +4,9 @@ module Smith
|
|
|
4
4
|
module Events
|
|
5
5
|
class Subscription
|
|
6
6
|
attr_reader :event_class, :handler, :predicate
|
|
7
|
+
# Internal registration order, assigned by Events.on; dispatch and the
|
|
8
|
+
# subscriptions snapshot sort by it.
|
|
9
|
+
attr_accessor :sequence_number
|
|
7
10
|
|
|
8
11
|
def initialize(event_class, handler:, predicate: nil)
|
|
9
12
|
@event_class = event_class
|
|
@@ -12,8 +15,13 @@ module Smith
|
|
|
12
15
|
@cancelled = false
|
|
13
16
|
end
|
|
14
17
|
|
|
18
|
+
# Cancelling flags the subscription and detaches it from the registry
|
|
19
|
+
# (so it cannot leak). The flag check against an in-flight emit
|
|
20
|
+
# snapshot is best-effort: a dispatch already past the check may still
|
|
21
|
+
# deliver once after cancel returns.
|
|
15
22
|
def cancel
|
|
16
23
|
@cancelled = true
|
|
24
|
+
Events.detach(self)
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
def cancelled?
|
|
@@ -82,6 +82,20 @@ module Smith
|
|
|
82
82
|
[
|
|
83
83
|
# ----- Anthropic -----
|
|
84
84
|
|
|
85
|
+
# Claude 5+ families (Fable/Mythos/Opus/Sonnet/Haiku): adaptive
|
|
86
|
+
# thinking, no temperature accepted (the API rejects an explicit
|
|
87
|
+
# temperature with a 400).
|
|
88
|
+
Rule.new(
|
|
89
|
+
provider: :anthropic,
|
|
90
|
+
matcher: lambda { |id|
|
|
91
|
+
m = id.match(/\Aclaude-(?:fable|mythos|opus|sonnet|haiku)-(\d+)/)
|
|
92
|
+
m && m[1].to_i >= 5
|
|
93
|
+
},
|
|
94
|
+
thinking_shape: :adaptive,
|
|
95
|
+
accepts_temperature: false,
|
|
96
|
+
tools_with_thinking_native: true,
|
|
97
|
+
tools_with_thinking_route: nil
|
|
98
|
+
),
|
|
85
99
|
# Opus 4.7+: adaptive thinking, no temperature accepted.
|
|
86
100
|
Rule.new(
|
|
87
101
|
provider: :anthropic,
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "dry-initializer"
|
|
4
4
|
|
|
5
|
+
require_relative "tool_routing"
|
|
6
|
+
|
|
5
7
|
module Smith
|
|
6
8
|
module Models
|
|
7
9
|
# Per-chat-construction request shaper. Mutates a RubyLLM::Chat
|
|
@@ -96,73 +98,11 @@ module Smith
|
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
def normalize_tools_routing
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return if tools.empty?
|
|
105
|
-
return unless thinking_active?
|
|
106
|
-
|
|
107
|
-
return if profile.tools_with_thinking_native
|
|
108
|
-
|
|
109
|
-
if profile.tools_with_thinking_route == :responses &&
|
|
110
|
-
Smith.config.openai_api_mode == :auto
|
|
111
|
-
merge_params(openai_api_mode: :responses)
|
|
112
|
-
@decisions << Decision.new(kind: :routed_via_responses, model_id: profile.model_id, detail: nil)
|
|
113
|
-
return
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
drop_incompatible_tools(tools)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def thinking_active?
|
|
120
|
-
thinking = chat.instance_variable_get(:@thinking)
|
|
121
|
-
return true if thinking&.enabled?
|
|
122
|
-
|
|
123
|
-
# Also active if we already translated to adaptive (in which case
|
|
124
|
-
# @thinking is nil but params carry the thinking spec).
|
|
125
|
-
params = chat.instance_variable_get(:@params) || {}
|
|
126
|
-
params.key?(:thinking) || params.key?(:reasoning) || params.key?(:reasoning_effort)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def drop_incompatible_tools(tools)
|
|
130
|
-
effective_endpoint = effective_endpoint_for_compatibility
|
|
131
|
-
incompatible = tools.reject do |tool|
|
|
132
|
-
spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
|
|
133
|
-
if defined?(Smith::Tool::Compatibility)
|
|
134
|
-
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: effective_endpoint)
|
|
135
|
-
else
|
|
136
|
-
true
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
return if incompatible.empty?
|
|
140
|
-
|
|
141
|
-
retained = tools - incompatible
|
|
142
|
-
chat.with_tools(*retained, replace: true)
|
|
143
|
-
|
|
144
|
-
incompatible.each do |tool|
|
|
145
|
-
@decisions << Decision.new(
|
|
146
|
-
kind: :tool_dropped,
|
|
147
|
-
model_id: profile.model_id,
|
|
148
|
-
detail: { tool: tool.class.name }
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Profile.endpoint_mode reports the INTENDED endpoint (per the
|
|
154
|
-
# inference rule). Smith.config.openai_api_mode policy can downgrade
|
|
155
|
-
# the EFFECTIVE endpoint — e.g., a profile with route :responses
|
|
156
|
-
# actually uses :chat_completions when openai_api_mode is :off.
|
|
157
|
-
# The compatibility check needs the effective endpoint to make
|
|
158
|
-
# the right drop/keep decision.
|
|
159
|
-
def effective_endpoint_for_compatibility
|
|
160
|
-
if profile.tools_with_thinking_route == :responses &&
|
|
161
|
-
Smith.config.openai_api_mode != :auto
|
|
162
|
-
:chat_completions
|
|
163
|
-
else
|
|
164
|
-
profile.endpoint_mode
|
|
165
|
-
end
|
|
101
|
+
ToolRouting.new(
|
|
102
|
+
chat: chat,
|
|
103
|
+
profile: profile,
|
|
104
|
+
decision_recorder: method(:record_decision)
|
|
105
|
+
).call
|
|
166
106
|
end
|
|
167
107
|
|
|
168
108
|
# with_params REPLACES @params in RubyLLM (chat.rb:96), so the
|
|
@@ -173,6 +113,10 @@ module Smith
|
|
|
173
113
|
chat.with_params(**existing, **new_params)
|
|
174
114
|
end
|
|
175
115
|
|
|
116
|
+
def record_decision(kind, model_id, detail)
|
|
117
|
+
@decisions << Decision.new(kind: kind, model_id: model_id, detail: detail)
|
|
118
|
+
end
|
|
119
|
+
|
|
176
120
|
def emit_trace
|
|
177
121
|
return if @decisions.empty?
|
|
178
122
|
return unless defined?(Smith::Trace)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Models
|
|
7
|
+
module ProviderQualifiedRegistry
|
|
8
|
+
def self.extended(registry)
|
|
9
|
+
registry.instance_variable_set(:@registry_monitor, Monitor.new)
|
|
10
|
+
registry.instance_variable_set(:@registry_index, {})
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find(model_id, provider: nil)
|
|
14
|
+
registry_monitor.synchronize do
|
|
15
|
+
registered_keys = registered_keys_for(model_id, provider:)
|
|
16
|
+
return nil if registered_keys.empty?
|
|
17
|
+
|
|
18
|
+
reject_ambiguous_lookup!(model_id, registered_keys)
|
|
19
|
+
resolve(registered_keys.first)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register(profile)
|
|
24
|
+
registry_monitor.synchronize do
|
|
25
|
+
profile = normalized_profile(profile)
|
|
26
|
+
key = registry_key(profile.model_id, profile.provider)
|
|
27
|
+
existing = key?(key) ? resolve(key) : nil
|
|
28
|
+
|
|
29
|
+
return profile if existing == profile
|
|
30
|
+
|
|
31
|
+
raise_collision!(key, existing, profile) if existing
|
|
32
|
+
|
|
33
|
+
super(key, profile)
|
|
34
|
+
index_profile(profile, key)
|
|
35
|
+
profile
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def all
|
|
40
|
+
registry_monitor.synchronize do
|
|
41
|
+
keys.map { |key| resolve(key) }.sort_by do |profile|
|
|
42
|
+
[profile.model_id, profile.provider.to_s]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def clear!
|
|
48
|
+
registry_monitor.synchronize do
|
|
49
|
+
@_container&.clear
|
|
50
|
+
@registry_index = {}
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
attr_reader :registry_monitor, :registry_index
|
|
57
|
+
|
|
58
|
+
def registered_keys_for(model_id, provider:)
|
|
59
|
+
normalized_id = normalize_key(model_id)
|
|
60
|
+
return registry_index.fetch(normalized_id, []) unless provider
|
|
61
|
+
|
|
62
|
+
key = registry_key(normalized_id, provider)
|
|
63
|
+
key?(key) ? [key] : []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def registry_key(model_id, provider)
|
|
67
|
+
[provider.to_sym, normalize_key(model_id)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reject_ambiguous_lookup!(model_id, registered_keys)
|
|
71
|
+
return unless registered_keys.length > 1
|
|
72
|
+
|
|
73
|
+
providers = registered_keys.map { |key| resolve(key).provider.to_s }.sort
|
|
74
|
+
raise AmbiguousProfileError,
|
|
75
|
+
"model #{normalize_key(model_id).inspect} has profiles for multiple providers: " \
|
|
76
|
+
"#{providers.join(", ")}; pass provider:"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def index_profile(profile, key)
|
|
80
|
+
model_id = normalize_key(profile.model_id)
|
|
81
|
+
registry_index[model_id] = [*registry_index.fetch(model_id, []), key].uniq.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalized_profile(profile)
|
|
85
|
+
Profile.new(
|
|
86
|
+
**profile.to_h,
|
|
87
|
+
model_id: normalize_key(profile.model_id).freeze,
|
|
88
|
+
provider: profile.provider.to_sym
|
|
89
|
+
)
|
|
90
|
+
rescue NoMethodError, TypeError
|
|
91
|
+
raise ArgumentError, "model profile must expose model_id, provider, and profile attributes"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def raise_collision!(key, existing, replacement)
|
|
95
|
+
raise CollisionError,
|
|
96
|
+
"model profile collision for #{key.inspect}: existing #{existing.inspect}, " \
|
|
97
|
+
"replacement #{replacement.inspect}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Models
|
|
7
|
+
class ToolRouting
|
|
8
|
+
TOOL_ENDPOINTS = %i[chat_completions responses].freeze
|
|
9
|
+
|
|
10
|
+
private_constant :TOOL_ENDPOINTS
|
|
11
|
+
|
|
12
|
+
extend Dry::Initializer
|
|
13
|
+
|
|
14
|
+
option :chat
|
|
15
|
+
option :profile
|
|
16
|
+
option :decision_recorder
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
return unless chat.respond_to?(:tools)
|
|
20
|
+
|
|
21
|
+
tools = chat.tools.values
|
|
22
|
+
return if tools.empty?
|
|
23
|
+
|
|
24
|
+
route_tools_with_thinking if thinking_active?
|
|
25
|
+
route_tools_for_compatibility(tools)
|
|
26
|
+
drop_incompatible_tools(tools)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def route_tools_with_thinking
|
|
32
|
+
return unless profile.provider == :openai
|
|
33
|
+
return if profile.tools_with_thinking_native
|
|
34
|
+
return unless profile.tools_with_thinking_route == :responses
|
|
35
|
+
return unless Smith.config.openai_api_mode == :auto
|
|
36
|
+
|
|
37
|
+
route_via_responses
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def route_tools_for_compatibility(tools)
|
|
41
|
+
return unless profile.provider == :openai
|
|
42
|
+
return unless Smith.config.openai_api_mode == :auto
|
|
43
|
+
return if responses_routed?
|
|
44
|
+
|
|
45
|
+
forced_endpoint = forced_tool_endpoint(tools)
|
|
46
|
+
return if forced_endpoint == :chat_completions
|
|
47
|
+
return unless forced_endpoint == :responses ||
|
|
48
|
+
compatible_tool_count(tools, :responses) > compatible_tool_count(tools, :chat_completions)
|
|
49
|
+
|
|
50
|
+
route_via_responses
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def route_via_responses
|
|
54
|
+
return if responses_routed?
|
|
55
|
+
|
|
56
|
+
merge_params(openai_api_mode: :responses)
|
|
57
|
+
record(:routed_via_responses)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def drop_incompatible_tools(tools)
|
|
61
|
+
endpoint = responses_routed? ? :responses : :chat_completions
|
|
62
|
+
incompatible = tools.reject { |tool| tool_compatible_with?(tool, endpoint) }
|
|
63
|
+
return if incompatible.empty?
|
|
64
|
+
|
|
65
|
+
remaining = tools - incompatible
|
|
66
|
+
forced_choice = reset_invalid_tool_choice(remaining)
|
|
67
|
+
chat.with_tools(*remaining, replace: true)
|
|
68
|
+
incompatible.each_with_index do |tool, index|
|
|
69
|
+
record(:tool_dropped, dropped_tool_detail(tool, forced_choice, first: index.zero?))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def dropped_tool_detail(tool, forced_choice, first:)
|
|
74
|
+
detail = { tool: tool.class.name }
|
|
75
|
+
if forced_choice && (tool.name.to_sym == forced_choice.to_sym || (forced_choice == :required && first))
|
|
76
|
+
detail[:forced_choice_reset] = forced_choice
|
|
77
|
+
end
|
|
78
|
+
detail
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def forced_tool_endpoint(tools)
|
|
82
|
+
choice = chat.tool_prefs[:choice]&.to_sym
|
|
83
|
+
return if %i[auto none required].include?(choice)
|
|
84
|
+
|
|
85
|
+
tool = tools.find { |candidate| candidate.name.to_sym == choice }
|
|
86
|
+
return unless tool
|
|
87
|
+
|
|
88
|
+
compatible_endpoints = TOOL_ENDPOINTS.select { |endpoint| tool_compatible_with?(tool, endpoint) }
|
|
89
|
+
compatible_endpoints.first if compatible_endpoints.one?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def reset_invalid_tool_choice(remaining)
|
|
93
|
+
choice = chat.tool_prefs[:choice]
|
|
94
|
+
return unless choice
|
|
95
|
+
return if %i[auto none].include?(choice)
|
|
96
|
+
return if choice == :required && remaining.any?
|
|
97
|
+
return if remaining.any? { |tool| tool.name.to_sym == choice.to_sym }
|
|
98
|
+
|
|
99
|
+
chat.tool_prefs[:choice] = nil
|
|
100
|
+
choice
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def compatible_tool_count(tools, endpoint)
|
|
104
|
+
tools.count { |tool| tool_compatible_with?(tool, endpoint) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def tool_compatible_with?(tool, endpoint)
|
|
108
|
+
return true unless defined?(Smith::Tool::Compatibility)
|
|
109
|
+
|
|
110
|
+
spec = tool.class.respond_to?(:compatible_with_spec) ? tool.class.compatible_with_spec : nil
|
|
111
|
+
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def thinking_active?
|
|
115
|
+
thinking = chat.instance_variable_get(:@thinking)
|
|
116
|
+
return true if thinking&.enabled?
|
|
117
|
+
|
|
118
|
+
current_params.key?(:thinking) || current_params.key?(:reasoning) ||
|
|
119
|
+
current_params.key?(:reasoning_effort)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def responses_routed?
|
|
123
|
+
mode = current_params[:openai_api_mode] || current_params["openai_api_mode"]
|
|
124
|
+
mode.to_s == "responses"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def current_params
|
|
128
|
+
chat.instance_variable_get(:@params) || {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def merge_params(**new_params)
|
|
132
|
+
chat.with_params(**current_params, **new_params)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record(kind, detail = nil)
|
|
136
|
+
decision_recorder.call(kind, profile.model_id, detail)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|