smith-agents 0.7.0 → 0.8.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 +226 -0
- data/README.md +71 -1
- data/docs/CONFIGURATION.md +22 -1
- 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 +25 -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 +104 -0
- data/lib/smith/agent/lifecycle.rb +7 -276
- data/lib/smith/agent/model_reference.rb +76 -0
- data/lib/smith/agent/provider_attempt.rb +28 -0
- data/lib/smith/agent/provider_candidate_sequence.rb +55 -0
- data/lib/smith/agent/provider_completion.rb +130 -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 +43 -0
- data/lib/smith/agent/usage_tracking.rb +113 -0
- data/lib/smith/agent.rb +29 -197
- 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/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/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 +71 -12
- data/lib/smith/tool_execution_not_admitted.rb +7 -0
- data/lib/smith/tool_failure_notification_failed.rb +94 -0
- 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 +3 -1
- data/lib/smith/workflow/deadline_enforcement.rb +20 -2
- data/lib/smith/workflow/execution.rb +2 -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/guarded_step_execution.rb +39 -0
- data/lib/smith/workflow/parallel.rb +14 -3
- data/lib/smith/workflow/persistence.rb +22 -19
- data/lib/smith/workflow/retry_execution.rb +5 -1
- data/lib/smith/workflow/thread_context_snapshot.rb +5 -2
- data/lib/smith/workflow/transition.rb +9 -4
- data/lib/smith/workflow/usage_entry.rb +12 -2
- data/lib/smith/workflow.rb +17 -130
- data/lib/smith.rb +3 -0
- metadata +78 -11
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
class Agent
|
|
9
|
+
class ProviderUsage < Dry::Struct
|
|
10
|
+
attribute :input_tokens, Types::Integer
|
|
11
|
+
attribute :output_tokens, Types::Integer
|
|
12
|
+
|
|
13
|
+
def self.from_message(message)
|
|
14
|
+
return unless message.respond_to?(:input_tokens) && message.respond_to?(:output_tokens)
|
|
15
|
+
|
|
16
|
+
input_tokens = message.input_tokens
|
|
17
|
+
output_tokens = message.output_tokens
|
|
18
|
+
return unless input_tokens.is_a?(Integer) && output_tokens.is_a?(Integer)
|
|
19
|
+
|
|
20
|
+
new(input_tokens:, output_tokens:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def total_tokens
|
|
24
|
+
input_tokens + output_tokens
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
module ReservedInputBridge
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def partition_inputs(kwargs)
|
|
9
|
+
input_values, chat_options = super
|
|
10
|
+
provider = input_values[:provider]
|
|
11
|
+
chat_options[:provider] = provider if provider && provider.to_sym != :unknown
|
|
12
|
+
[input_values, chat_options]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def apply_configuration(chat_object, input_values:, persist_instructions:)
|
|
16
|
+
super(
|
|
17
|
+
chat_object,
|
|
18
|
+
input_values: reserved_inputs_for(chat_object, input_values),
|
|
19
|
+
persist_instructions:
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reserved_inputs_for(chat_object, input_values)
|
|
24
|
+
llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
|
|
25
|
+
model = llm_chat.model if llm_chat.respond_to?(:model)
|
|
26
|
+
profile = resolve_profile(
|
|
27
|
+
model&.id || input_values[:model_id],
|
|
28
|
+
provider: actual_provider(llm_chat) || input_values[:provider]
|
|
29
|
+
)
|
|
30
|
+
return input_values unless profile
|
|
31
|
+
|
|
32
|
+
input_values.merge(
|
|
33
|
+
model_id: profile.model_id,
|
|
34
|
+
provider: profile.provider,
|
|
35
|
+
endpoint_mode: profile.endpoint_mode
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
module UsageEntryRecording
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def record_usage(agent_class, agent_result, attempt_kind, model_reference)
|
|
9
|
+
return unless agent_result.usage_known?
|
|
10
|
+
|
|
11
|
+
model_reference = coerce_model_reference(model_reference)
|
|
12
|
+
entry = build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
|
|
13
|
+
accumulate_usage(agent_result, entry)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def build_usage_entry(agent_class, agent_result, attempt_kind, model_reference)
|
|
17
|
+
Workflow::UsageEntry.new(
|
|
18
|
+
usage_id: SecureRandom.uuid,
|
|
19
|
+
agent_name: agent_class.register_as,
|
|
20
|
+
model: model_reference.model_id,
|
|
21
|
+
provider: model_reference.provider,
|
|
22
|
+
input_tokens: agent_result.input_tokens,
|
|
23
|
+
output_tokens: agent_result.output_tokens,
|
|
24
|
+
cost: agent_result.cost,
|
|
25
|
+
attempt_kind: attempt_kind,
|
|
26
|
+
recorded_at: Time.now.utc.iso8601
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def accumulate_usage(agent_result, entry)
|
|
31
|
+
@usage_mutex.synchronize do
|
|
32
|
+
@total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
|
|
33
|
+
@total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
|
|
34
|
+
@usage_entries << entry
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def coerce_model_reference(value)
|
|
39
|
+
ModelReference.coerce(value)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "completion_usage_recording"
|
|
4
|
+
require_relative "usage_entry_recording"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Agent
|
|
8
|
+
module UsageTracking
|
|
9
|
+
include CompletionUsageRecording
|
|
10
|
+
include UsageEntryRecording
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def account_failed_attempt(error, model_reference, agent_class)
|
|
15
|
+
return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
|
|
16
|
+
|
|
17
|
+
input = error.input_tokens
|
|
18
|
+
output = error.output_tokens
|
|
19
|
+
return unless input.is_a?(Integer) && output.is_a?(Integer)
|
|
20
|
+
|
|
21
|
+
record_failed_usage(agent_class, model_reference, input, output, :failed_attempt)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def account_completed_prefix(agent_class, model_reference, messages)
|
|
25
|
+
completion = Completion.from_messages(response: nil, messages: messages)
|
|
26
|
+
record_completion_usage(agent_class, completion, :partial_attempt, model_reference)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_kind)
|
|
30
|
+
model_reference = coerce_model_reference(model_reference)
|
|
31
|
+
cost = Smith::Pricing.compute_cost(
|
|
32
|
+
model: model_reference.model_id,
|
|
33
|
+
provider: model_reference.provider,
|
|
34
|
+
input_tokens:,
|
|
35
|
+
output_tokens:
|
|
36
|
+
)
|
|
37
|
+
agent_result = Workflow::AgentResult.new(
|
|
38
|
+
content: nil,
|
|
39
|
+
input_tokens:,
|
|
40
|
+
output_tokens:,
|
|
41
|
+
cost: cost,
|
|
42
|
+
model_used: model_reference.model_id,
|
|
43
|
+
provider_used: model_reference.provider
|
|
44
|
+
)
|
|
45
|
+
Thread.current[:smith_failed_agent_results] ||= []
|
|
46
|
+
Thread.current[:smith_failed_agent_results] << agent_result
|
|
47
|
+
record_usage(agent_class, agent_result, attempt_kind, model_reference)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def snapshot_and_finalize(agent_class, completion, model_reference)
|
|
51
|
+
model_reference = coerce_model_reference(model_reference)
|
|
52
|
+
agent_result = Workflow::AgentResult.new(
|
|
53
|
+
content: completion.content,
|
|
54
|
+
input_tokens: completion.input_tokens,
|
|
55
|
+
output_tokens: completion.output_tokens,
|
|
56
|
+
cost: nil,
|
|
57
|
+
model_used: model_reference.model_id,
|
|
58
|
+
provider_used: model_reference.provider
|
|
59
|
+
)
|
|
60
|
+
Thread.current[:smith_last_agent_result] = agent_result
|
|
61
|
+
emit_token_usage(agent_result)
|
|
62
|
+
compute_agent_cost(agent_result)
|
|
63
|
+
record_completion_usage(agent_class, completion, :completed_attempt, model_reference)
|
|
64
|
+
|
|
65
|
+
agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
|
|
66
|
+
raise_blank_output!(agent_class, agent_result)
|
|
67
|
+
agent_result
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def raise_blank_output!(agent_class, agent_result)
|
|
71
|
+
return unless blank_agent_output?(agent_result.content)
|
|
72
|
+
|
|
73
|
+
raise Smith::BlankAgentOutputError.new(
|
|
74
|
+
agent_name: agent_class.register_as,
|
|
75
|
+
model_used: agent_result.model_used
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def blank_agent_output?(content)
|
|
80
|
+
return true if content.nil?
|
|
81
|
+
return content.strip.empty? if content.is_a?(String)
|
|
82
|
+
|
|
83
|
+
false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def emit_token_usage(agent_result)
|
|
87
|
+
return unless agent_result.usage_known?
|
|
88
|
+
|
|
89
|
+
Smith::Trace.record(
|
|
90
|
+
type: :token_usage,
|
|
91
|
+
data: {
|
|
92
|
+
input_tokens: agent_result.input_tokens,
|
|
93
|
+
output_tokens: agent_result.output_tokens,
|
|
94
|
+
model: agent_result.model_used,
|
|
95
|
+
provider: agent_result.provider_used
|
|
96
|
+
}.compact
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def compute_agent_cost(agent_result)
|
|
101
|
+
return unless agent_result.usage_known?
|
|
102
|
+
|
|
103
|
+
model = agent_result.model_used
|
|
104
|
+
agent_result.cost = Smith::Pricing.compute_cost(
|
|
105
|
+
model: model,
|
|
106
|
+
provider: agent_result.provider_used,
|
|
107
|
+
input_tokens: agent_result.input_tokens,
|
|
108
|
+
output_tokens: agent_result.output_tokens
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/smith/agent.rb
CHANGED
|
@@ -6,8 +6,14 @@ require_relative "tool"
|
|
|
6
6
|
|
|
7
7
|
module Smith
|
|
8
8
|
class Agent < RubyLLM::Agent
|
|
9
|
+
require_relative "agent/model_reference"
|
|
10
|
+
|
|
9
11
|
EXECUTION_IDENTITY_UNSET = Object.new.freeze
|
|
12
|
+
TOOL_BUDGET_EXHAUSTION_UNSET = Object.new.freeze
|
|
13
|
+
TOOL_BUDGET_EXHAUSTION_POLICIES = %i[raise complete].freeze
|
|
14
|
+
|
|
10
15
|
private_constant :EXECUTION_IDENTITY_UNSET
|
|
16
|
+
private_constant :TOOL_BUDGET_EXHAUSTION_UNSET, :TOOL_BUDGET_EXHAUSTION_POLICIES
|
|
11
17
|
|
|
12
18
|
# Reserved input names auto-injected by the normalizer into
|
|
13
19
|
# runtime_context. User-side `inputs :name` calls cannot redeclare
|
|
@@ -23,8 +29,9 @@ module Smith
|
|
|
23
29
|
subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
|
|
24
30
|
subclass.instance_variable_set(:@output_schema_class, @output_schema_class)
|
|
25
31
|
subclass.instance_variable_set(:@data_volume, @data_volume)
|
|
26
|
-
subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup)
|
|
32
|
+
subclass.instance_variable_set(:@fallback_models_list, @fallback_models_list&.dup&.freeze)
|
|
27
33
|
subclass.instance_variable_set(:@model_block, @model_block)
|
|
34
|
+
subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
|
|
28
35
|
subclass.instance_variable_set(:@execution_identity, nil)
|
|
29
36
|
subclass.instance_variable_set(:@registered_name, nil)
|
|
30
37
|
end
|
|
@@ -44,6 +51,17 @@ module Smith
|
|
|
44
51
|
@budget_config = opts
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
def tool_budget_exhaustion(value = TOOL_BUDGET_EXHAUSTION_UNSET)
|
|
55
|
+
return @tool_budget_exhaustion || :raise if value.equal?(TOOL_BUDGET_EXHAUSTION_UNSET)
|
|
56
|
+
|
|
57
|
+
policy = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
58
|
+
unless TOOL_BUDGET_EXHAUSTION_POLICIES.include?(policy)
|
|
59
|
+
raise ArgumentError, "tool_budget_exhaustion must be :raise or :complete"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@tool_budget_exhaustion = policy
|
|
63
|
+
end
|
|
64
|
+
|
|
47
65
|
def guardrails(klass = nil)
|
|
48
66
|
return @guardrails_class if klass.nil?
|
|
49
67
|
|
|
@@ -62,15 +80,6 @@ module Smith
|
|
|
62
80
|
@data_volume = value
|
|
63
81
|
end
|
|
64
82
|
|
|
65
|
-
def fallback_models(*models)
|
|
66
|
-
return @fallback_models_list if models.empty?
|
|
67
|
-
|
|
68
|
-
entries = models.flatten.compact.map(&:to_s)
|
|
69
|
-
raise Smith::WorkflowError, "fallback_models entries must not be blank" if entries.any?(&:empty?)
|
|
70
|
-
|
|
71
|
-
@fallback_models_list = entries.uniq
|
|
72
|
-
end
|
|
73
|
-
|
|
74
83
|
def register_as(name = nil, publish: true)
|
|
75
84
|
return @registered_name if name.nil?
|
|
76
85
|
|
|
@@ -87,52 +96,6 @@ module Smith
|
|
|
87
96
|
Registry.ensure_registered(name.to_sym, self)
|
|
88
97
|
end
|
|
89
98
|
|
|
90
|
-
# Extends RubyLLM::Agent.model with a block-form for context-driven
|
|
91
|
-
# resolution at chat-construction time.
|
|
92
|
-
#
|
|
93
|
-
# Static form `model "gpt-5-mini"`:
|
|
94
|
-
# Stores into @chat_kwargs[:model] via RubyLLM's existing path.
|
|
95
|
-
# Model id is fixed at class-load time.
|
|
96
|
-
#
|
|
97
|
-
# Block form `model { |context| ... }`:
|
|
98
|
-
# Stores the block as @model_block. Smith's lifecycle resolves it
|
|
99
|
-
# at chat-construction time using the workflow's @context (Hash).
|
|
100
|
-
# Return value must be a non-empty string; non-string / empty / nil
|
|
101
|
-
# returns surface as Smith::AgentError at the resolution point
|
|
102
|
-
# (see Smith::Agent::Lifecycle#build_model_chain).
|
|
103
|
-
#
|
|
104
|
-
# Mutually exclusive within a single declaration: passing both a
|
|
105
|
-
# string id and a block raises ArgumentError. Redeclaring with the
|
|
106
|
-
# other form clears the previous setting (static replaces block,
|
|
107
|
-
# block replaces static).
|
|
108
|
-
#
|
|
109
|
-
# Composes with `fallback_models`: resolved primary, then declared
|
|
110
|
-
# fallbacks, in order. Same path as static-form fallback.
|
|
111
|
-
def model(model_id = nil, **options, &block)
|
|
112
|
-
if block
|
|
113
|
-
raise ArgumentError, "model can take a string id OR a block, not both" if model_id || !options.empty?
|
|
114
|
-
|
|
115
|
-
@model_block = block
|
|
116
|
-
# Clear any stale `@chat_kwargs[:model]` from a prior static-form
|
|
117
|
-
# declaration. Smith's workflow lifecycle resolves block-form
|
|
118
|
-
# correctly via `build_model_chain` (which checks @model_block
|
|
119
|
-
# first), but RubyLLM's direct `chat()` and `with_rails_chat_record`
|
|
120
|
-
# paths splat `**chat_kwargs` to the constructor; without this
|
|
121
|
-
# delete, those paths would silently use the stale static id.
|
|
122
|
-
# This is the only place Smith mutates a RubyLLM-owned ivar; the
|
|
123
|
-
# mutation is well-scoped (only :model, only on block-form
|
|
124
|
-
# declaration) and matches RubyLLM's own pattern of dup'ing
|
|
125
|
-
# @chat_kwargs through its `inherited` hook.
|
|
126
|
-
@chat_kwargs ||= {}
|
|
127
|
-
@chat_kwargs.delete(:model)
|
|
128
|
-
else
|
|
129
|
-
@model_block = nil
|
|
130
|
-
super
|
|
131
|
-
end
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
attr_reader :model_block
|
|
135
|
-
|
|
136
99
|
private
|
|
137
100
|
|
|
138
101
|
def canonical_registration_name(name)
|
|
@@ -146,13 +109,6 @@ module Smith
|
|
|
146
109
|
|
|
147
110
|
public
|
|
148
111
|
|
|
149
|
-
# Whether this agent class has any model configured (static or block).
|
|
150
|
-
# Smith::Workflow::Execution uses this as a precondition for invoking
|
|
151
|
-
# the agent; agents declared without a model are skipped.
|
|
152
|
-
def model_configured?
|
|
153
|
-
!chat_kwargs[:model].nil? || !@model_block.nil?
|
|
154
|
-
end
|
|
155
|
-
|
|
156
112
|
# MERGING override: getter always returns user-declared ∪ reserved;
|
|
157
113
|
# setter validates user names against reserved + stores only user
|
|
158
114
|
# names. RubyLLM's bare `@input_names = names` (agent.rb:96) REPLACES;
|
|
@@ -176,140 +132,16 @@ module Smith
|
|
|
176
132
|
|
|
177
133
|
@input_names = user_names.freeze
|
|
178
134
|
end
|
|
179
|
-
|
|
180
|
-
# Closes the `inputs` contract at the chat() boundary AND runs the
|
|
181
|
-
# Smith::Models::Normalizer. Hook lives here (not in
|
|
182
|
-
# Lifecycle#attempt_model) so direct callers that construct a chat
|
|
183
|
-
# outside the workflow lifecycle are normalized too. Without this
|
|
184
|
-
# placement, model-specific request translation would only fire for
|
|
185
|
-
# workflow-driven calls.
|
|
186
|
-
#
|
|
187
|
-
# Single profile lookup: resolved once via Models.find_or_infer and
|
|
188
|
-
# passed through both inject_reserved_inputs and Normalizer.apply!.
|
|
189
|
-
def chat(**kwargs)
|
|
190
|
-
# Resolve model from explicit kwarg first, then fall back to the
|
|
191
|
-
# class-level chat_kwargs[:model] (set by `model "..."`). The
|
|
192
|
-
# explicit kwarg path fires from Lifecycle#attempt_model (passes
|
|
193
|
-
# the resolved primary or fallback model); the chat_kwargs path
|
|
194
|
-
# fires from direct callers like `Agent.chat` with no args.
|
|
195
|
-
model_id = kwargs[:model] || chat_kwargs[:model]
|
|
196
|
-
profile = resolve_profile(model_id)
|
|
197
|
-
kwargs = inject_reserved_inputs(kwargs, profile)
|
|
198
|
-
kwargs = nil_fill_declared_inputs(kwargs)
|
|
199
|
-
|
|
200
|
-
llm_chat = install_tool_execution_context(super)
|
|
201
|
-
Smith::Models::Normalizer.apply!(llm_chat, profile: profile) if profile
|
|
202
|
-
llm_chat
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
def create(**kwargs)
|
|
206
|
-
install_tool_execution_context(super)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def create!(**kwargs)
|
|
210
|
-
install_tool_execution_context(super)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def find(id, **kwargs)
|
|
214
|
-
install_tool_execution_context(super)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
# Normalizes the |ctx| DSL across RubyLLM's block-form attribute setters.
|
|
218
|
-
#
|
|
219
|
-
# RubyLLM evaluates these blocks via `runtime.instance_exec(&block)`,
|
|
220
|
-
# which sets `self` to the runtime_context but passes NO positional
|
|
221
|
-
# arguments, so `tools do |ctx| ctx.form_kind end` would silently
|
|
222
|
-
# bind `ctx = nil` and crash on the first method call. Smith's `model`
|
|
223
|
-
# block-form already uses `block.call(@context)` (an explicit Hash arg),
|
|
224
|
-
# giving agent authors a uniform `|ctx|` mental model. These overrides
|
|
225
|
-
# carry that convention through to RubyLLM's setters by wrapping any
|
|
226
|
-
# block so `|ctx|` receives the runtime_context AND `self` is still the
|
|
227
|
-
# runtime (preserving RubyLLM's bare-method-dispatch convention for
|
|
228
|
-
# zero-arity blocks).
|
|
229
|
-
#
|
|
230
|
-
# Behavior matrix:
|
|
231
|
-
# tools do ... end (arity 0): preserved as-is; bare method
|
|
232
|
-
# calls dispatch to runtime via
|
|
233
|
-
# instance_exec (RubyLLM idiom)
|
|
234
|
-
# tools do |ctx| ... end (arity 1): wrapped; ctx receives runtime
|
|
235
|
-
# AND self is runtime, so both
|
|
236
|
-
# `ctx.x` and bare `x` work
|
|
237
|
-
#
|
|
238
|
-
# Lambdas with arity 0 are preserved as-is (strict-arity safe). The
|
|
239
|
-
# wrapping path uses Proc semantics, so extra args don't raise.
|
|
240
|
-
def tools(*tools, &block)
|
|
241
|
-
return super unless block
|
|
242
|
-
|
|
243
|
-
super(&wrap_runtime_block(block))
|
|
244
|
-
end
|
|
245
|
-
|
|
246
|
-
def instructions(text = nil, **prompt_locals, &block)
|
|
247
|
-
return super unless block
|
|
248
|
-
|
|
249
|
-
super(text, **prompt_locals, &wrap_runtime_block(block))
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def params(**params_kwargs, &block)
|
|
253
|
-
return super unless block
|
|
254
|
-
|
|
255
|
-
super(&wrap_runtime_block(block))
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def headers(**headers_kwargs, &block)
|
|
259
|
-
return super unless block
|
|
260
|
-
|
|
261
|
-
super(&wrap_runtime_block(block))
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def schema(value = nil, &block)
|
|
265
|
-
return super unless block
|
|
266
|
-
|
|
267
|
-
super(&wrap_runtime_block(block))
|
|
268
|
-
end
|
|
269
|
-
|
|
270
|
-
private
|
|
271
|
-
|
|
272
|
-
def install_tool_execution_context(chat_object)
|
|
273
|
-
return unless chat_object
|
|
274
|
-
|
|
275
|
-
llm_chat = chat_object.respond_to?(:to_llm) ? chat_object.to_llm : chat_object
|
|
276
|
-
Tool::ChatExecutionContext.install(llm_chat)
|
|
277
|
-
chat_object
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def resolve_profile(model_id)
|
|
281
|
-
return nil unless model_id
|
|
282
|
-
return nil unless defined?(Smith::Models)
|
|
283
|
-
|
|
284
|
-
Smith::Models.find_or_infer(model_id)
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
def inject_reserved_inputs(kwargs, profile)
|
|
288
|
-
return kwargs unless profile
|
|
289
|
-
|
|
290
|
-
reserved = {
|
|
291
|
-
model_id: profile.model_id,
|
|
292
|
-
provider: profile.provider,
|
|
293
|
-
endpoint_mode: profile.endpoint_mode
|
|
294
|
-
}
|
|
295
|
-
# User-provided values win on key collision.
|
|
296
|
-
reserved.merge(kwargs)
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
def nil_fill_declared_inputs(kwargs)
|
|
300
|
-
inputs.each_with_object(kwargs.dup) do |name, result|
|
|
301
|
-
result[name] = nil unless result.key?(name)
|
|
302
|
-
end
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def wrap_runtime_block(user_block)
|
|
306
|
-
return user_block if user_block.arity.zero?
|
|
307
|
-
|
|
308
|
-
proc do |*|
|
|
309
|
-
runtime = self
|
|
310
|
-
runtime.instance_exec(runtime, &user_block)
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
135
|
end
|
|
314
136
|
end
|
|
315
137
|
end
|
|
138
|
+
|
|
139
|
+
require_relative "agent/dynamic_configuration"
|
|
140
|
+
require_relative "agent/fallback_configuration"
|
|
141
|
+
require_relative "agent/reserved_input_bridge"
|
|
142
|
+
require_relative "agent/chat_construction"
|
|
143
|
+
|
|
144
|
+
Smith::Agent.extend(Smith::Agent::DynamicConfiguration)
|
|
145
|
+
Smith::Agent.extend(Smith::Agent::FallbackConfiguration)
|
|
146
|
+
Smith::Agent.extend(Smith::Agent::ReservedInputBridge)
|
|
147
|
+
Smith::Agent.extend(Smith::Agent::ChatConstruction)
|
|
@@ -5,17 +5,41 @@ module Smith
|
|
|
5
5
|
module ObservationMasking
|
|
6
6
|
SYSTEM_ROLES = %i[system].push("system").freeze
|
|
7
7
|
|
|
8
|
-
def self.apply(messages, strategy:)
|
|
8
|
+
def self.apply(messages, strategy:, seed_message_count: 0)
|
|
9
9
|
return messages unless strategy
|
|
10
10
|
|
|
11
11
|
window = strategy[:window]
|
|
12
12
|
return messages unless window
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
prefix_count = strategy[:preserve_seed] == true ? seed_message_count : 0
|
|
15
|
+
prefix = messages.first(prefix_count)
|
|
16
|
+
dynamic_messages = messages.drop(prefix_count)
|
|
17
|
+
|
|
18
|
+
prefix + system_messages(dynamic_messages) + recent_observations(dynamic_messages, window)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.system_message?(message)
|
|
22
|
+
SYSTEM_ROLES.include?(message[:role] || message["role"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.system_messages(messages)
|
|
26
|
+
messages.select { |message| system_message?(message) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.recent_observations(messages, window)
|
|
30
|
+
raise ArgumentError, "negative array size" if window.negative?
|
|
31
|
+
return [] if window.zero?
|
|
32
|
+
|
|
33
|
+
selected = []
|
|
34
|
+
messages.reverse_each do |message|
|
|
35
|
+
next if system_message?(message)
|
|
36
|
+
|
|
37
|
+
selected << message
|
|
38
|
+
break if selected.length == window
|
|
16
39
|
end
|
|
17
|
-
|
|
40
|
+
selected.reverse
|
|
18
41
|
end
|
|
42
|
+
private_class_method :system_message?, :system_messages, :recent_observations
|
|
19
43
|
end
|
|
20
44
|
end
|
|
21
45
|
end
|
|
@@ -5,10 +5,11 @@ module Smith
|
|
|
5
5
|
class Session
|
|
6
6
|
attr_reader :messages
|
|
7
7
|
|
|
8
|
-
def initialize(messages:, context_manager:, persisted_context:)
|
|
8
|
+
def initialize(messages:, context_manager:, persisted_context:, seed_message_count: 0)
|
|
9
9
|
@messages = messages
|
|
10
10
|
@context_manager = context_manager
|
|
11
11
|
@persisted_context = persisted_context
|
|
12
|
+
@seed_message_count = seed_message_count
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def inject_state!
|
|
@@ -26,7 +27,11 @@ module Smith
|
|
|
26
27
|
return @messages unless @context_manager
|
|
27
28
|
|
|
28
29
|
strategy = @context_manager.session_strategy
|
|
29
|
-
ObservationMasking.apply(
|
|
30
|
+
ObservationMasking.apply(
|
|
31
|
+
@messages,
|
|
32
|
+
strategy: strategy,
|
|
33
|
+
seed_message_count: @seed_message_count
|
|
34
|
+
)
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
def prepare!
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class DiagnosticText
|
|
7
|
+
MAX_BYTES = 64 * 1024
|
|
8
|
+
TRUNCATION_MARKER = "...[truncated]"
|
|
9
|
+
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
10
|
+
STRING_BYTESLICE = String.instance_method(:byteslice)
|
|
11
|
+
MODULE_MATCH = Module.instance_method(:===)
|
|
12
|
+
private_constant :TRUNCATION_MARKER, :STRING_BYTESIZE, :STRING_BYTESLICE, :MODULE_MATCH
|
|
13
|
+
|
|
14
|
+
extend Dry::Initializer
|
|
15
|
+
|
|
16
|
+
param :value
|
|
17
|
+
option :max_bytes, default: proc { MAX_BYTES }
|
|
18
|
+
|
|
19
|
+
def self.capture(value, max_bytes: MAX_BYTES) = new(value, max_bytes:).call
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
validate_limit!
|
|
23
|
+
text = utf8_prefix
|
|
24
|
+
return text.freeze if text.bytesize <= max_bytes
|
|
25
|
+
|
|
26
|
+
truncate(text)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def validate_limit!
|
|
32
|
+
return if max_bytes.is_a?(Integer) && max_bytes >= TRUNCATION_MARKER.bytesize
|
|
33
|
+
|
|
34
|
+
raise ArgumentError, "diagnostic text limit is too small"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def utf8_prefix
|
|
38
|
+
source = string_value
|
|
39
|
+
limit = (max_bytes * 4) + TRUNCATION_MARKER.bytesize
|
|
40
|
+
prefix = STRING_BYTESLICE.bind_call(source, 0, limit)
|
|
41
|
+
prefix.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "\uFFFD")
|
|
42
|
+
rescue EncodingError
|
|
43
|
+
"unavailable diagnostic text"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def string_value
|
|
47
|
+
return value if MODULE_MATCH.bind_call(String, value)
|
|
48
|
+
|
|
49
|
+
String(value)
|
|
50
|
+
rescue StandardError
|
|
51
|
+
"unavailable diagnostic text"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def truncate(text)
|
|
55
|
+
budget = max_bytes - TRUNCATION_MARKER.bytesize
|
|
56
|
+
prefix = +""
|
|
57
|
+
|
|
58
|
+
text.each_char do |character|
|
|
59
|
+
break if prefix.bytesize + character.bytesize > budget
|
|
60
|
+
|
|
61
|
+
prefix << character
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
(prefix << TRUNCATION_MARKER).freeze
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -43,11 +43,24 @@ module Smith
|
|
|
43
43
|
|
|
44
44
|
def self.check_pricing(report)
|
|
45
45
|
configured = ::Smith.config.pricing.is_a?(Hash) && !::Smith.config.pricing.empty?
|
|
46
|
+
unless configured
|
|
47
|
+
report.add(
|
|
48
|
+
name: "config.pricing",
|
|
49
|
+
status: :warn,
|
|
50
|
+
message: "No pricing configured",
|
|
51
|
+
detail: "RunResult.total_cost will be 0.0"
|
|
52
|
+
)
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
::Smith::Pricing.validate_catalog!
|
|
57
|
+
report.add(name: "config.pricing", status: :pass, message: "Pricing configured")
|
|
58
|
+
rescue ::Smith::PricingConfigurationError => e
|
|
46
59
|
report.add(
|
|
47
60
|
name: "config.pricing",
|
|
48
|
-
status:
|
|
49
|
-
message:
|
|
50
|
-
detail:
|
|
61
|
+
status: :fail,
|
|
62
|
+
message: "Pricing catalog invalid",
|
|
63
|
+
detail: e.message
|
|
51
64
|
)
|
|
52
65
|
end
|
|
53
66
|
end
|