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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Agent
|
|
7
|
+
module InvocationPreparation
|
|
8
|
+
WORKFLOW_CONTINUATION_MESSAGE =
|
|
9
|
+
"Use the preceding assistant result as input and perform your assigned workflow step."
|
|
10
|
+
|
|
11
|
+
private_constant :WORKFLOW_CONTINUATION_MESSAGE
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def bridge_workflow_inputs(agent_class)
|
|
16
|
+
return {} unless @context.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
declared = agent_class.inputs || []
|
|
19
|
+
user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
|
|
20
|
+
user_declared.to_h do |name|
|
|
21
|
+
[name, @context[name]]
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add_prepared_input(chat, prepared_input)
|
|
26
|
+
return unless prepared_input
|
|
27
|
+
|
|
28
|
+
prepared_input = provider_safe_prepared_input(prepared_input)
|
|
29
|
+
system_messages, other_messages = prepared_input.partition do |message|
|
|
30
|
+
message_role(message) == :system
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
merge_system_messages!(chat, system_messages) if system_messages.any?
|
|
34
|
+
other_messages.each { |message| add_message(chat, message) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def provider_safe_prepared_input(prepared_input)
|
|
38
|
+
messages = prepared_input.to_a
|
|
39
|
+
return messages unless workflow_handoff?(messages)
|
|
40
|
+
|
|
41
|
+
messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def workflow_handoff?(messages)
|
|
45
|
+
message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
|
|
46
|
+
return false unless message
|
|
47
|
+
return false unless message_role(message) == :assistant
|
|
48
|
+
return false unless defined?(@last_output) && !@last_output.nil?
|
|
49
|
+
|
|
50
|
+
message_content(message) == @last_output
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def merge_system_messages!(chat, prepared_system_messages)
|
|
54
|
+
return append_system_messages(chat, prepared_system_messages) unless chat.respond_to?(:messages)
|
|
55
|
+
|
|
56
|
+
combined_contents = existing_system_contents(chat) + prepared_system_contents(prepared_system_messages)
|
|
57
|
+
return if combined_contents.empty?
|
|
58
|
+
return append_system_messages(chat, prepared_system_messages) unless combined_contents.all?(String)
|
|
59
|
+
|
|
60
|
+
if chat.respond_to?(:with_instructions)
|
|
61
|
+
chat.with_instructions(combined_contents.join("\n\n"))
|
|
62
|
+
else
|
|
63
|
+
append_system_messages(chat, prepared_system_messages)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def append_system_messages(chat, messages)
|
|
68
|
+
messages.each { |message| add_message(chat, message) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def existing_system_contents(chat)
|
|
72
|
+
chat.messages.filter_map do |message|
|
|
73
|
+
message.content if message_role(message) == :system
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def prepared_system_contents(messages)
|
|
78
|
+
messages.filter_map { |message| message_content(message) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def add_message(chat, message)
|
|
82
|
+
attributes = if message.is_a?(Hash)
|
|
83
|
+
message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
84
|
+
else
|
|
85
|
+
message
|
|
86
|
+
end
|
|
87
|
+
attributes = provider_safe_message(attributes) if attributes.is_a?(Hash)
|
|
88
|
+
chat.add_message(attributes)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# A structured agent output recorded as a session message (StepCompletion#append_accepted_output)
|
|
92
|
+
# carries a Hash/Array content. RubyLLM's Message#normalize_content treats a Hash content as
|
|
93
|
+
# { text:, ...attachments } and opens each value as a file, so replaying a prior structured
|
|
94
|
+
# output to the next agent in a workflow session raises Errno::ENOENT. Serialize non-string
|
|
95
|
+
# content to JSON so the provider sees the prior output as text; the session store keeps the raw
|
|
96
|
+
# value (last_output stays structured), only this provider-facing copy is serialized. Genuine
|
|
97
|
+
# multimodal attachments are supplied through the provider's own with: mechanism, never as a bare
|
|
98
|
+
# Hash message content in a workflow session.
|
|
99
|
+
def provider_safe_message(attributes)
|
|
100
|
+
content = attributes[:content]
|
|
101
|
+
return attributes if content.nil? || content.is_a?(String)
|
|
102
|
+
|
|
103
|
+
attributes.merge(content: json_message_content(content))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def json_message_content(content)
|
|
107
|
+
JSON.generate(content)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
content.to_s
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def message_role(message)
|
|
113
|
+
message_attribute(message, :role)&.to_sym
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def message_content(message)
|
|
117
|
+
message_attribute(message, :content)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def message_attribute(message, name)
|
|
121
|
+
return message.public_send(name) if message.respond_to?(name)
|
|
122
|
+
return message[name] if message.respond_to?(:key?) && message.key?(name)
|
|
123
|
+
|
|
124
|
+
message[name.to_s]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "provider_completion"
|
|
4
|
+
require_relative "usage_tracking"
|
|
5
|
+
|
|
3
6
|
module Smith
|
|
4
7
|
class Agent
|
|
5
8
|
module Lifecycle
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
private_constant :WORKFLOW_CONTINUATION_MESSAGE
|
|
9
|
-
|
|
10
|
-
TRANSIENT_ERRORS = [
|
|
11
|
-
RubyLLM::ServerError, RubyLLM::ServiceUnavailableError,
|
|
12
|
-
RubyLLM::OverloadedError, RubyLLM::RateLimitError
|
|
13
|
-
].freeze
|
|
9
|
+
include ProviderCompletion
|
|
10
|
+
include UsageTracking
|
|
14
11
|
|
|
15
12
|
private
|
|
16
13
|
|
|
@@ -23,275 +20,11 @@ module Smith
|
|
|
23
20
|
|
|
24
21
|
def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
|
|
25
22
|
check_deadline!
|
|
26
|
-
|
|
27
|
-
snapshot_and_finalize(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
# Returns [response, model_used] as local data — no shared mutable
|
|
31
|
-
# state. Previously this method set `@last_attempt_model` on the
|
|
32
|
-
# workflow instance and `snapshot_and_finalize` read it back; under
|
|
33
|
-
# parallel fan-out, two branches sharing the workflow could race
|
|
34
|
-
# and attribute the wrong model to the wrong response. Local data
|
|
35
|
-
# eliminates the race entirely.
|
|
36
|
-
def complete_with_provider(agent_class, prepared_input, output_schema:)
|
|
37
|
-
models = build_model_chain(agent_class)
|
|
38
|
-
|
|
39
|
-
models.each_with_index do |model_id, index|
|
|
40
|
-
check_deadline! if index.positive?
|
|
41
|
-
response = attempt_model(agent_class, prepared_input, model_id, output_schema:)
|
|
42
|
-
return [response, model_id]
|
|
43
|
-
rescue Smith::Error
|
|
44
|
-
raise
|
|
45
|
-
rescue StandardError => e
|
|
46
|
-
account_failed_attempt(e, model_id, agent_class)
|
|
47
|
-
raise Smith::AgentError, e.message unless fallback_eligible?(e) && index < models.length - 1
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def build_model_chain(agent_class)
|
|
52
|
-
primary = if agent_class.respond_to?(:model_block) && agent_class.model_block
|
|
53
|
-
resolve_dynamic_model(agent_class)
|
|
54
|
-
else
|
|
55
|
-
agent_class.chat_kwargs[:model]
|
|
56
|
-
end
|
|
57
|
-
fallbacks = agent_class.fallback_models || []
|
|
58
|
-
[primary, *fallbacks].compact
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Evaluates a block-form `model` declaration with the workflow's
|
|
62
|
-
# @context (Hash, defaults to {} when uninitialized). The block
|
|
63
|
-
# must return a non-empty string model id; any other value
|
|
64
|
-
# surfaces as Smith::AgentError so the workflow's failure handler
|
|
65
|
-
# treats it as a step failure rather than a silent miss.
|
|
66
|
-
def resolve_dynamic_model(agent_class)
|
|
67
|
-
result = agent_class.model_block.call(@context || {})
|
|
68
|
-
return result if result.is_a?(String) && !result.empty?
|
|
69
|
-
|
|
70
|
-
raise Smith::AgentError,
|
|
71
|
-
"model block for #{agent_class} must return a non-empty string; got #{result.inspect}"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def attempt_model(agent_class, prepared_input, model_id, output_schema:)
|
|
75
|
-
chat = agent_class.chat(model: model_id, **bridge_workflow_inputs(agent_class))
|
|
76
|
-
add_prepared_input(chat, prepared_input)
|
|
77
|
-
chat = chat.with_schema(output_schema) if output_schema
|
|
78
|
-
chat.complete
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Bridges declared agent `inputs` from the workflow's @context Hash
|
|
82
|
-
# to the agent invocation kwargs, so block-form RubyLLM DSLs (tools,
|
|
83
|
-
# instructions, params, headers, schema) can access workflow-context
|
|
84
|
-
# data via bare method calls on `self` inside the block (RubyLLM
|
|
85
|
-
# invokes these via `runtime.instance_exec(&block)`, exposing each
|
|
86
|
-
# declared input as a singleton method on the runtime object).
|
|
87
|
-
# Smith's own `model` block-form already receives @context directly
|
|
88
|
-
# via `block.call(@context)`; this bridge gives runtime_context the
|
|
89
|
-
# same surface for the RubyLLM-owned blocks.
|
|
90
|
-
#
|
|
91
|
-
# Bridges ONLY user-declared inputs — reserved names
|
|
92
|
-
# (Smith::Agent::RESERVED_INPUT_NAMES: model_id, provider,
|
|
93
|
-
# endpoint_mode) are auto-injected by Smith::Agent.chat from the
|
|
94
|
-
# resolved profile, NOT from @context. The slice prevents the bridge
|
|
95
|
-
# from accidentally passing through stale or wrong values that
|
|
96
|
-
# happen to live in @context under those keys.
|
|
97
|
-
#
|
|
98
|
-
# Contract: declared inputs are ALWAYS passed (with nil when absent
|
|
99
|
-
# from @context). The declaration is the contract — `inputs :form_kind`
|
|
100
|
-
# promises that `form_kind` will be a callable singleton method on
|
|
101
|
-
# the runtime regardless of whether @context happens to have a value.
|
|
102
|
-
# This eliminates `respond_to?` defensiveness in agent blocks and
|
|
103
|
-
# mirrors the silent-nil semantics agent authors get from `ctx[:k]`
|
|
104
|
-
# in the model block. Non-Hash @context short-circuits.
|
|
105
|
-
def bridge_workflow_inputs(agent_class)
|
|
106
|
-
return {} unless @context.is_a?(Hash)
|
|
107
|
-
|
|
108
|
-
declared = agent_class.inputs || []
|
|
109
|
-
user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
|
|
110
|
-
user_declared.to_h do |name|
|
|
111
|
-
[name, @context[name]]
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def add_prepared_input(chat, prepared_input)
|
|
116
|
-
return unless prepared_input
|
|
117
|
-
|
|
118
|
-
prepared_input = provider_safe_prepared_input(prepared_input)
|
|
119
|
-
system_messages, other_messages = prepared_input.partition do |message|
|
|
120
|
-
message_role(message) == :system
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
merge_system_messages!(chat, system_messages) if system_messages.any?
|
|
124
|
-
other_messages.each { |message| add_message(chat, message) }
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
def provider_safe_prepared_input(prepared_input)
|
|
128
|
-
messages = prepared_input.to_a
|
|
129
|
-
return messages unless workflow_handoff?(messages)
|
|
130
|
-
|
|
131
|
-
messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def workflow_handoff?(messages)
|
|
135
|
-
message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
|
|
136
|
-
return false unless message
|
|
137
|
-
return false unless message_role(message) == :assistant
|
|
138
|
-
return false unless defined?(@last_output) && !@last_output.nil?
|
|
139
|
-
|
|
140
|
-
message_content(message) == @last_output
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def merge_system_messages!(chat, prepared_system_messages)
|
|
144
|
-
return prepared_system_messages.each { |message| add_message(chat, message) } unless chat.respond_to?(:messages)
|
|
145
|
-
|
|
146
|
-
existing_system_contents = chat.messages.filter_map do |message|
|
|
147
|
-
message.content if message_role(message) == :system
|
|
148
|
-
end
|
|
149
|
-
prepared_system_contents = prepared_system_messages.filter_map do |message|
|
|
150
|
-
message_content(message)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
combined_contents = existing_system_contents + prepared_system_contents
|
|
154
|
-
return if combined_contents.empty?
|
|
155
|
-
unless combined_contents.all?(String)
|
|
156
|
-
return prepared_system_messages.each { |message| add_message(chat, message) }
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
if chat.respond_to?(:with_instructions)
|
|
160
|
-
chat.with_instructions(combined_contents.join("\n\n"))
|
|
161
|
-
else
|
|
162
|
-
prepared_system_messages.each { |message| add_message(chat, message) }
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
def add_message(chat, message)
|
|
167
|
-
attributes = if message.is_a?(Hash)
|
|
168
|
-
message.transform_keys { |key| key.respond_to?(:to_sym) ? key.to_sym : key }
|
|
169
|
-
else
|
|
170
|
-
message
|
|
171
|
-
end
|
|
172
|
-
chat.add_message(attributes)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
def message_role(message)
|
|
176
|
-
message_attribute(message, :role)&.to_sym
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def message_content(message)
|
|
180
|
-
message_attribute(message, :content)
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
def message_attribute(message, name)
|
|
184
|
-
return message.public_send(name) if message.respond_to?(name)
|
|
185
|
-
return message[name] if message.respond_to?(:key?) && message.key?(name)
|
|
186
|
-
|
|
187
|
-
message[name.to_s]
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def fallback_eligible?(error)
|
|
191
|
-
TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) } ||
|
|
192
|
-
error.is_a?(Faraday::TimeoutError) ||
|
|
193
|
-
error.is_a?(Faraday::ConnectionFailed)
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# `agent_class` is now a parameter (was previously implicit via
|
|
197
|
-
# `@last_attempt_model`-only path). The caller (`complete_with_provider`)
|
|
198
|
-
# has the local already, so no shared mutable state is needed.
|
|
199
|
-
# Records the failed attempt's tokens via the unified `record_usage`
|
|
200
|
-
# helper, marking the entry as `:failed_attempt`.
|
|
201
|
-
def account_failed_attempt(error, model_id, agent_class)
|
|
202
|
-
return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
|
|
203
|
-
|
|
204
|
-
input = error.input_tokens
|
|
205
|
-
output = error.output_tokens
|
|
206
|
-
return unless input.is_a?(Integer) && output.is_a?(Integer)
|
|
207
|
-
|
|
208
|
-
cost = Smith::Pricing.compute_cost(model: model_id, input_tokens: input, output_tokens: output)
|
|
209
|
-
agent_result = Workflow::AgentResult.new(
|
|
210
|
-
content: nil, input_tokens: input, output_tokens: output, cost: cost, model_used: model_id
|
|
211
|
-
)
|
|
212
|
-
Thread.current[:smith_failed_agent_results] ||= []
|
|
213
|
-
Thread.current[:smith_failed_agent_results] << agent_result
|
|
214
|
-
record_usage(agent_class, agent_result, :failed_attempt, model_id)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def snapshot_and_finalize(agent_class, response, model_used)
|
|
218
|
-
agent_result = Workflow::AgentResult.from_response(response, response&.content, model_used: model_used)
|
|
219
|
-
Thread.current[:smith_last_agent_result] = agent_result
|
|
220
|
-
emit_token_usage(agent_result)
|
|
221
|
-
compute_agent_cost(agent_result)
|
|
222
|
-
record_usage(agent_class, agent_result, :completed_attempt, agent_result.model_used)
|
|
223
|
-
|
|
224
|
-
agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
|
|
225
|
-
raise_blank_output!(agent_class, agent_result)
|
|
226
|
-
agent_result
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def raise_blank_output!(agent_class, agent_result)
|
|
230
|
-
return unless blank_agent_output?(agent_result.content)
|
|
231
|
-
|
|
232
|
-
raise Smith::BlankAgentOutputError.new(
|
|
233
|
-
agent_name: agent_class.register_as,
|
|
234
|
-
model_used: agent_result.model_used
|
|
23
|
+
attempt = complete_with_provider(agent_class, prepared_input, output_schema:)
|
|
24
|
+
snapshot_and_finalize(
|
|
25
|
+
agent_class, attempt.completion, attempt.model_reference, attempt_id: attempt.attempt_id
|
|
235
26
|
)
|
|
236
27
|
end
|
|
237
|
-
|
|
238
|
-
def blank_agent_output?(content)
|
|
239
|
-
return true if content.nil?
|
|
240
|
-
return content.strip.empty? if content.is_a?(String)
|
|
241
|
-
|
|
242
|
-
false
|
|
243
|
-
end
|
|
244
|
-
|
|
245
|
-
def emit_token_usage(agent_result)
|
|
246
|
-
return unless agent_result.usage_known?
|
|
247
|
-
|
|
248
|
-
Smith::Trace.record(
|
|
249
|
-
type: :token_usage,
|
|
250
|
-
data: { input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens }
|
|
251
|
-
)
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
def compute_agent_cost(agent_result)
|
|
255
|
-
return unless agent_result.usage_known?
|
|
256
|
-
|
|
257
|
-
model = agent_result.model_used
|
|
258
|
-
agent_result.cost = Smith::Pricing.compute_cost(
|
|
259
|
-
model: model, input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens
|
|
260
|
-
)
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
# Single critical section: all three of `@total_tokens`,
|
|
264
|
-
# `@total_cost`, and `@usage_entries` update under one mutex
|
|
265
|
-
# acquisition. Replaces the prior `accumulate_usage` which took
|
|
266
|
-
# the mutex twice (once for tokens, once for cost) — under
|
|
267
|
-
# parallel fan-out two branches could interleave between those
|
|
268
|
-
# blocks, leaving totals momentarily inconsistent. Adding the
|
|
269
|
-
# entry append in a third pass would have widened the window;
|
|
270
|
-
# one pass closes it entirely.
|
|
271
|
-
#
|
|
272
|
-
# `@usage_mutex` is eagerly initialized in `Workflow#initialize`
|
|
273
|
-
# AND `Workflow#restore_state` (since `from_state` allocates
|
|
274
|
-
# without `initialize`), so it's always present here.
|
|
275
|
-
def record_usage(agent_class, agent_result, attempt_kind, model_id)
|
|
276
|
-
return unless agent_result.usage_known?
|
|
277
|
-
|
|
278
|
-
entry = Workflow::UsageEntry.new(
|
|
279
|
-
usage_id: SecureRandom.uuid,
|
|
280
|
-
agent_name: agent_class.register_as,
|
|
281
|
-
model: model_id,
|
|
282
|
-
input_tokens: agent_result.input_tokens,
|
|
283
|
-
output_tokens: agent_result.output_tokens,
|
|
284
|
-
cost: agent_result.cost,
|
|
285
|
-
attempt_kind: attempt_kind,
|
|
286
|
-
recorded_at: Time.now.utc.iso8601
|
|
287
|
-
)
|
|
288
|
-
|
|
289
|
-
@usage_mutex.synchronize do
|
|
290
|
-
@total_tokens = (@total_tokens || 0) + agent_result.input_tokens + agent_result.output_tokens
|
|
291
|
-
@total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
|
|
292
|
-
@usage_entries << entry
|
|
293
|
-
end
|
|
294
|
-
end
|
|
295
28
|
end
|
|
296
29
|
end
|
|
297
30
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
require "ruby_llm"
|
|
5
|
+
|
|
6
|
+
require_relative "../types"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Agent < RubyLLM::Agent
|
|
10
|
+
class ModelReference < Dry::Struct
|
|
11
|
+
ModelId = Types::String.constructor do |value|
|
|
12
|
+
normalized = value.to_s.dup
|
|
13
|
+
raise ArgumentError, "model id must not be blank" if normalized.empty?
|
|
14
|
+
|
|
15
|
+
normalized.freeze
|
|
16
|
+
end
|
|
17
|
+
Provider = Types::Symbol.optional.constructor { |value| value&.to_sym }
|
|
18
|
+
|
|
19
|
+
private_constant :ModelId, :Provider
|
|
20
|
+
|
|
21
|
+
attribute :model_id, ModelId
|
|
22
|
+
attribute :provider, Provider
|
|
23
|
+
|
|
24
|
+
def initialize(attributes)
|
|
25
|
+
super
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.coerce(value, provider: nil)
|
|
30
|
+
return value if value.is_a?(self)
|
|
31
|
+
return from_hash(value) if value.is_a?(Hash)
|
|
32
|
+
return parse(value) if provider.nil? && value.is_a?(String) && value.include?("/")
|
|
33
|
+
|
|
34
|
+
new(model_id: value, provider:)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.from_hash(value)
|
|
38
|
+
attributes = value.transform_keys(&:to_sym)
|
|
39
|
+
new(model_id: attributes[:model_id] || attributes[:model], provider: attributes[:provider])
|
|
40
|
+
end
|
|
41
|
+
private_class_method :from_hash
|
|
42
|
+
|
|
43
|
+
# Inverse of #to_s: the first slash separates provider from model,
|
|
44
|
+
# so a reference whose model id itself contains slashes round-trips
|
|
45
|
+
# ("openrouter/openai/gpt-5" -> provider :openrouter,
|
|
46
|
+
# model "openai/gpt-5"). An explicit `provider:` keyword keeps the
|
|
47
|
+
# string literal and is never re-split.
|
|
48
|
+
def self.parse(value)
|
|
49
|
+
provider, model_id = value.split("/", 2)
|
|
50
|
+
raise ArgumentError, "provider segment in #{value.inspect} must not be blank" if provider.empty?
|
|
51
|
+
|
|
52
|
+
new(model_id:, provider:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def key
|
|
56
|
+
[provider, model_id].freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# True when the two references can address the same physical model:
|
|
60
|
+
# an unqualified reference delegates provider selection to RubyLLM,
|
|
61
|
+
# so it can resolve to any provider serving the same model id.
|
|
62
|
+
def same_candidate?(other)
|
|
63
|
+
model_id == other.model_id &&
|
|
64
|
+
(provider.nil? || other.provider.nil? || provider == other.provider)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def chat_options
|
|
68
|
+
{ model: model_id, provider: }.compact.freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_s
|
|
72
|
+
provider ? "#{provider}/#{model_id}" : model_id
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
require_relative "model_reference"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Agent
|
|
10
|
+
class ProviderAttempt < Dry::Struct
|
|
11
|
+
attribute :completion, Types::Any.optional
|
|
12
|
+
attribute :model_reference, Types.Instance(ModelReference)
|
|
13
|
+
attribute :error, Types.Instance(StandardError).optional
|
|
14
|
+
# One attempt = one chat completion (including any provider tool loop).
|
|
15
|
+
# `attempt_id` joins the attempt's usage entries to its single measured
|
|
16
|
+
# duration; `duration_ms` is nil when the attempt failed before the
|
|
17
|
+
# completion call started.
|
|
18
|
+
attribute :attempt_id, Types::String.optional.default(nil)
|
|
19
|
+
attribute :duration_ms, Types::Integer.optional.default(nil)
|
|
20
|
+
|
|
21
|
+
def self.success(completion:, model_reference:, attempt_id: nil, duration_ms: nil)
|
|
22
|
+
new(completion:, model_reference:, error: nil, attempt_id:, duration_ms:)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.failure(error:, model_reference:, attempt_id: nil, duration_ms: nil)
|
|
26
|
+
new(completion: nil, model_reference:, error:, attempt_id:, duration_ms:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def success?
|
|
30
|
+
error.nil?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# Monotonic timing and trace emission for one provider attempt: the
|
|
6
|
+
# attempt's single measured duration wraps the whole chat completion
|
|
7
|
+
# (including any provider-side tool loop). Per-network-round timing
|
|
8
|
+
# belongs to the host's RubyLLM notification subscriptions, not Smith.
|
|
9
|
+
module ProviderCallTiming
|
|
10
|
+
class Timer
|
|
11
|
+
def self.start
|
|
12
|
+
new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
17
|
+
@stopped_at = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Idempotent: the first stop wins, so a rescue-path stop after a
|
|
21
|
+
# success-path stop cannot stretch the measurement.
|
|
22
|
+
def stop
|
|
23
|
+
@stopped_at ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
self
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def elapsed_ms
|
|
28
|
+
ending = @stopped_at || Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
29
|
+
((ending - @started_at) * 1000).round
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# One `:provider_call` trace per attempt: success, provider failure, or
|
|
36
|
+
# aborted (a non-provider error that re-raises). Usage entries from the
|
|
37
|
+
# same attempt share its attempt_id; join there instead of summing
|
|
38
|
+
# durations across entries. duration_ms is present only when the timed
|
|
39
|
+
# provider call actually started: a failure before dispatch (model
|
|
40
|
+
# resolution, chat construction) emits its attempt without a duration.
|
|
41
|
+
def record_provider_call_trace(attempt, attempt_index, aborted: false)
|
|
42
|
+
Smith::Trace.record(
|
|
43
|
+
type: :provider_call,
|
|
44
|
+
data: {
|
|
45
|
+
model: attempt.model_reference.model_id,
|
|
46
|
+
provider: attempt.model_reference.provider,
|
|
47
|
+
duration_ms: attempt.duration_ms,
|
|
48
|
+
attempt_id: attempt.attempt_id,
|
|
49
|
+
attempt_index: attempt_index,
|
|
50
|
+
outcome: provider_call_outcome(attempt, aborted)
|
|
51
|
+
}.compact
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def provider_call_outcome(attempt, aborted)
|
|
56
|
+
return :aborted if aborted
|
|
57
|
+
return :success if attempt.success?
|
|
58
|
+
|
|
59
|
+
:failure
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Aborted (non-provider) attempts emit a :provider_call too, so the
|
|
63
|
+
# prefix-accounted usage entries stamped with this attempt_id always
|
|
64
|
+
# have their join target; the error then propagates unchanged.
|
|
65
|
+
def failed_provider_attempt(error, observed_reference, attempt_id, timer, attempt_index)
|
|
66
|
+
attempt = ProviderAttempt.failure(
|
|
67
|
+
error: error, model_reference: observed_reference, attempt_id:, duration_ms: timer&.elapsed_ms
|
|
68
|
+
)
|
|
69
|
+
record_provider_call_trace(attempt, attempt_index, aborted: !provider_failure?(error))
|
|
70
|
+
attempt
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
class ProviderCandidateSequence
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(references)
|
|
9
|
+
@references = references.freeze
|
|
10
|
+
@remaining_by_provider = references.each_with_object(Hash.new(0)) do |reference, counts|
|
|
11
|
+
counts[reference.provider] += 1
|
|
12
|
+
end
|
|
13
|
+
@unavailable_providers = {}
|
|
14
|
+
@available_count = references.length
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns self so an exhausted sequence can never be mistaken for
|
|
18
|
+
# an attempt result by a caller that forgets to fail closed.
|
|
19
|
+
def each
|
|
20
|
+
return enum_for(:each) unless block_given?
|
|
21
|
+
|
|
22
|
+
references.each_with_index do |reference, index|
|
|
23
|
+
consume(reference)
|
|
24
|
+
yield reference, index unless unavailable?(reference.provider)
|
|
25
|
+
end
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def suppress(provider)
|
|
30
|
+
return unless provider
|
|
31
|
+
return if unavailable?(provider)
|
|
32
|
+
|
|
33
|
+
unavailable_providers[provider] = true
|
|
34
|
+
@available_count -= remaining_by_provider.fetch(provider, 0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def fallback_available?
|
|
38
|
+
@available_count.positive?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :references, :remaining_by_provider, :unavailable_providers
|
|
44
|
+
|
|
45
|
+
def consume(reference)
|
|
46
|
+
remaining_by_provider[reference.provider] -= 1
|
|
47
|
+
@available_count -= 1 unless unavailable?(reference.provider)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def unavailable?(provider)
|
|
51
|
+
unavailable_providers.key?(provider)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|