smith-agents 0.11.0 → 0.12.1
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 +712 -0
- data/README.md +622 -15
- data/UPSTREAM_PROPOSAL.md +1 -0
- data/docs/CONFIGURATION.md +80 -14
- data/docs/PATTERNS.md +30 -13
- data/docs/PERSISTENCE.md +733 -5
- data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
- data/lib/generators/smith/install/install_generator.rb +67 -2
- data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
- data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
- data/lib/smith/agent/completion.rb +32 -6
- data/lib/smith/agent/completion_usage_recording.rb +23 -14
- data/lib/smith/agent/content_bytes.rb +98 -0
- data/lib/smith/agent/content_parts.rb +149 -0
- data/lib/smith/agent/invocation_preparation.rb +37 -14
- data/lib/smith/agent/lifecycle.rb +16 -5
- data/lib/smith/agent/observed_model.rb +29 -0
- data/lib/smith/agent/output_contract_check.rb +53 -0
- data/lib/smith/agent/output_integrity.rb +137 -0
- data/lib/smith/agent/output_policy.rb +92 -0
- data/lib/smith/agent/output_recovery.rb +129 -0
- data/lib/smith/agent/provider_attempt.rb +6 -0
- data/lib/smith/agent/provider_call_timing.rb +13 -4
- data/lib/smith/agent/provider_completion.rb +35 -32
- data/lib/smith/agent/provider_failure_handling.rb +5 -1
- data/lib/smith/agent/provider_request.rb +36 -0
- data/lib/smith/agent/provider_streaming.rb +155 -0
- data/lib/smith/agent/provider_usage.rb +70 -3
- data/lib/smith/agent/usage_entry_recording.rb +4 -2
- data/lib/smith/agent/usage_traces.rb +9 -2
- data/lib/smith/agent/usage_tracking.rb +64 -51
- data/lib/smith/agent.rb +5 -0
- data/lib/smith/artifacts/active_record_store.rb +79 -0
- data/lib/smith/artifacts/file.rb +45 -18
- data/lib/smith/artifacts/memory.rb +47 -36
- data/lib/smith/artifacts/s3.rb +126 -0
- data/lib/smith/artifacts/scoped_store.rb +55 -7
- data/lib/smith/artifacts/store_rules.rb +49 -0
- data/lib/smith/artifacts.rb +98 -1
- data/lib/smith/batch/message_shape.rb +36 -0
- data/lib/smith/batch.rb +166 -0
- data/lib/smith/budget/amount.rb +25 -0
- data/lib/smith/budget/continuation.rb +153 -0
- data/lib/smith/budget/host_ledger.rb +137 -0
- data/lib/smith/budget/ledger.rb +4 -2
- data/lib/smith/budget/ledger_state_transition.rb +7 -5
- data/lib/smith/budget/request_hold.rb +211 -0
- data/lib/smith/budget/spent_reservation.rb +18 -0
- data/lib/smith/child_run.rb +41 -0
- data/lib/smith/child_runs_failed.rb +56 -0
- data/lib/smith/content.rb +96 -0
- data/lib/smith/context/session.rb +14 -2
- data/lib/smith/context/state_injection.rb +10 -5
- data/lib/smith/context.rb +28 -0
- data/lib/smith/doctor/checks/artifacts.rb +103 -0
- data/lib/smith/doctor/checks/configuration.rb +0 -11
- data/lib/smith/doctor/checks/durability.rb +20 -18
- data/lib/smith/doctor/checks/state_model.rb +63 -0
- data/lib/smith/doctor/installer.rb +9 -2
- data/lib/smith/doctor.rb +4 -0
- data/lib/smith/errors.rb +249 -2
- data/lib/smith/idempotency_key/canonical.rb +74 -0
- data/lib/smith/idempotency_key.rb +145 -0
- data/lib/smith/launchers/inline.rb +46 -0
- data/lib/smith/message.rb +76 -0
- data/lib/smith/models/tool_routing.rb +47 -44
- data/lib/smith/output_failure.rb +130 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
- data/lib/smith/pricing/catalog_validation.rb +91 -0
- data/lib/smith/pricing.rb +95 -64
- data/lib/smith/providers/anthropic/batches.rb +116 -0
- data/lib/smith/providers/batch_exchange.rb +77 -0
- data/lib/smith/providers/openai/responses.rb +45 -20
- data/lib/smith/providers/output_limit.rb +68 -0
- data/lib/smith/providers/request_holds.rb +139 -0
- data/lib/smith/providers/response_facts.rb +108 -0
- data/lib/smith/providers/schema_constraint_check.rb +141 -0
- data/lib/smith/providers/schema_constraints.rb +87 -0
- data/lib/smith/providers/schema_rules.rb +66 -0
- data/lib/smith/providers/stop_reason.rb +39 -0
- data/lib/smith/providers/stream_facts.rb +192 -0
- data/lib/smith/response_memo.rb +152 -0
- data/lib/smith/setting_constructors.rb +47 -0
- data/lib/smith/streaming/json_field.rb +136 -0
- data/lib/smith/streaming/json_string.rb +91 -0
- data/lib/smith/streaming/session.rb +77 -0
- data/lib/smith/streaming/think_tags.rb +67 -0
- data/lib/smith/streaming.rb +64 -0
- data/lib/smith/testing/checks.rb +58 -0
- data/lib/smith/testing/hooks.rb +85 -0
- data/lib/smith/testing/minitest.rb +37 -0
- data/lib/smith/testing/rspec.rb +52 -0
- data/lib/smith/testing/stub.rb +110 -0
- data/lib/smith/testing.rb +158 -0
- data/lib/smith/tool/scoped_context.rb +9 -12
- data/lib/smith/tool.rb +14 -0
- data/lib/smith/trace/logger.rb +1 -1
- data/lib/smith/trace/memory.rb +1 -1
- data/lib/smith/trace/open_telemetry.rb +1 -1
- data/lib/smith/trace.rb +11 -4
- data/lib/smith/untrusted.rb +74 -0
- data/lib/smith/version.rb +2 -2
- data/lib/smith/workflow/active_clock.rb +61 -0
- data/lib/smith/workflow/agent_result.rb +37 -0
- data/lib/smith/workflow/artifact_integration.rb +57 -7
- data/lib/smith/workflow/await_step.rb +42 -0
- data/lib/smith/workflow/batch_charges.rb +74 -0
- data/lib/smith/workflow/batch_execution.rb +149 -0
- data/lib/smith/workflow/batch_holds.rb +73 -0
- data/lib/smith/workflow/batch_wait.rb +134 -0
- data/lib/smith/workflow/budget_integration.rb +11 -5
- data/lib/smith/workflow/budget_pause.rb +51 -0
- data/lib/smith/workflow/child_run_declarations.rb +127 -0
- data/lib/smith/workflow/child_run_execution.rb +150 -0
- data/lib/smith/workflow/child_run_results.rb +55 -0
- data/lib/smith/workflow/composite/effects.rb +37 -6
- data/lib/smith/workflow/composite/error.rb +1 -1
- data/lib/smith/workflow/composite/error_evidence.rb +9 -9
- data/lib/smith/workflow/composite/input.rb +11 -4
- data/lib/smith/workflow/context_keys.rb +77 -0
- data/lib/smith/workflow/deadline_enforcement.rb +1 -1
- data/lib/smith/workflow/deterministic_execution.rb +61 -10
- data/lib/smith/workflow/deterministic_step.rb +55 -6
- data/lib/smith/workflow/dsl.rb +59 -0
- data/lib/smith/workflow/durability.rb +139 -30
- data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
- data/lib/smith/workflow/event_integration.rb +9 -0
- data/lib/smith/workflow/execution.rb +30 -23
- data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
- data/lib/smith/workflow/failure_reconstructor.rb +27 -4
- data/lib/smith/workflow/failure_record.rb +6 -1
- data/lib/smith/workflow/failure_record_validator.rb +11 -4
- data/lib/smith/workflow/fanout_execution.rb +5 -8
- data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
- data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
- data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
- data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/execution_successors.rb +16 -2
- data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
- data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
- data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
- data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
- data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
- data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
- data/lib/smith/workflow/graph/transition_contract.rb +19 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
- data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
- data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
- data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
- data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
- data/lib/smith/workflow/graph.rb +12 -0
- data/lib/smith/workflow/guarded_step_execution.rb +48 -3
- data/lib/smith/workflow/message_batch.rb +6 -0
- data/lib/smith/workflow/message_roles.rb +25 -0
- data/lib/smith/workflow/message_value_normalizer.rb +2 -1
- data/lib/smith/workflow/nested_execution.rb +47 -4
- data/lib/smith/workflow/orchestrator_worker.rb +9 -3
- data/lib/smith/workflow/parallel_execution.rb +10 -0
- data/lib/smith/workflow/persistence.rb +136 -13
- data/lib/smith/workflow/remembered_calls.rb +92 -0
- data/lib/smith/workflow/retry_execution.rb +68 -9
- data/lib/smith/workflow/retry_wait.rb +134 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
- data/lib/smith/workflow/step_completion.rb +36 -7
- data/lib/smith/workflow/step_context.rb +21 -0
- data/lib/smith/workflow/step_io.rb +132 -0
- data/lib/smith/workflow/step_stream.rb +52 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
- data/lib/smith/workflow/transition.rb +267 -13
- data/lib/smith/workflow/transition_actionability.rb +2 -0
- data/lib/smith/workflow/usage_entry.rb +24 -3
- data/lib/smith/workflow/wait_completion.rb +131 -0
- data/lib/smith/workflow/wait_execution.rb +180 -0
- data/lib/smith/workflow/wait_record.rb +157 -0
- data/lib/smith/workflow.rb +113 -17
- data/lib/smith.rb +101 -45
- metadata +88 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Launchers
|
|
5
|
+
# Runs each child to its end, one after another, in the parent's
|
|
6
|
+
# process, and returns their results, so the parent's step completes at
|
|
7
|
+
# once. Children run under the parent's deadline but with no tool
|
|
8
|
+
# evidence or attribution of the parent's. A child left waiting, or one
|
|
9
|
+
# that raised, is reported failed (with the error's class). For tests
|
|
10
|
+
# and scripts: a long map holds the parent's step for as long as it runs.
|
|
11
|
+
class Inline
|
|
12
|
+
def inline? = true
|
|
13
|
+
|
|
14
|
+
def launch(children:, parent_key:, await_token:) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
deadline = Tool.current_deadline
|
|
16
|
+
children.map { |child| run_child(child, deadline) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def run_child(child, deadline)
|
|
22
|
+
workflow = child.build
|
|
23
|
+
workflow.instance_variable_set(:@inherited_deadline, deadline) if deadline
|
|
24
|
+
run = apart { workflow.run! }
|
|
25
|
+
return failed(child, workflow.state, "waiting") if workflow.waiting?
|
|
26
|
+
|
|
27
|
+
ChildRun.result(ref: child.ref, run_result: run)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
failed(child, nil, DiagnosticText.error_class_name(e))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def failed(child, state, error)
|
|
33
|
+
{ ref: child.ref, status: "failed", state: state&.to_s, output: nil,
|
|
34
|
+
error: DiagnosticText.capture(error.to_s, max_bytes: 256) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apart(&)
|
|
38
|
+
tracker = Tool.current_tool_execution_tracker
|
|
39
|
+
Tool.current_tool_execution_tracker = nil
|
|
40
|
+
Attribution.carrying(nil, &)
|
|
41
|
+
ensure
|
|
42
|
+
Tool.current_tool_execution_tracker = tracker
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "content"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "workflow/message_roles"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
# Messages for a step's `input:` and the `seed_messages` builder. Content
|
|
9
|
+
# is a String, or an Array of 1 to MAX_PARTS Smith::Content parts:
|
|
10
|
+
#
|
|
11
|
+
# Smith::Message.user("Summarize the contract.")
|
|
12
|
+
# Smith::Message.system([Smith::Content.text(style_guide, cache: true)])
|
|
13
|
+
#
|
|
14
|
+
# A message with parts carries `"parts" => true` beside its role and
|
|
15
|
+
# content. No agent output and no message a host admits
|
|
16
|
+
# (`append_session_messages!`) can carry it, so a structured output that
|
|
17
|
+
# looks like parts stays data. Only a user message takes documents and
|
|
18
|
+
# images; system and assistant messages, and every seed message (built
|
|
19
|
+
# before the run can store an artifact), take text parts.
|
|
20
|
+
module Message
|
|
21
|
+
MAX_PARTS = 100
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def user(content) = build("user", content)
|
|
26
|
+
|
|
27
|
+
def assistant(content) = build("assistant", content)
|
|
28
|
+
|
|
29
|
+
def system(content) = build("system", content)
|
|
30
|
+
|
|
31
|
+
def build(role, content)
|
|
32
|
+
return { "role" => role, "content" => content.dup.freeze }.freeze if content.is_a?(String)
|
|
33
|
+
|
|
34
|
+
check!({ "role" => role, "content" => content, "parts" => true })
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether a message Hash (String or Symbol keys) is one with parts.
|
|
38
|
+
def parts?(message)
|
|
39
|
+
message.is_a?(Hash) && (message["parts"] == true || message[:parts] == true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A seed message with parts: text parts only.
|
|
43
|
+
def check_seed!(message)
|
|
44
|
+
checked = check!(message)
|
|
45
|
+
return checked if checked["content"].all? { |part| part["type"] == "text" }
|
|
46
|
+
|
|
47
|
+
raise WorkflowError, "a seed message takes text parts only; send documents and images through a step's input:"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A message with parts, checked and returned frozen with String keys.
|
|
51
|
+
def check!(message)
|
|
52
|
+
fields = message.is_a?(Hash) ? message.transform_keys(&:to_s) : {}
|
|
53
|
+
role = Workflow::MessageRoles.role(fields, %w[system user assistant])
|
|
54
|
+
unless role && shaped?(fields)
|
|
55
|
+
raise WorkflowError, "a message with content parts is { role:, content: [parts], parts: true } with a " \
|
|
56
|
+
"system, user or assistant role and 1 to #{MAX_PARTS} parts"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
{ "role" => role, "content" => checked_parts(role, fields["content"]), "parts" => true }.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def shaped?(fields)
|
|
63
|
+
content = fields["content"]
|
|
64
|
+
fields.keys.sort == %w[content parts role] && fields["parts"] == true && content.is_a?(Array) &&
|
|
65
|
+
content.length.between?(1, MAX_PARTS)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def checked_parts(role, content)
|
|
69
|
+
parts = content.map { |part| Content.check!(part) }.freeze
|
|
70
|
+
return parts if role == "user" || parts.all? { |part| part["type"] == "text" }
|
|
71
|
+
|
|
72
|
+
raise WorkflowError, "a #{role} message takes text parts only"
|
|
73
|
+
end
|
|
74
|
+
private_class_method :build, :shaped?, :checked_parts
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -15,41 +15,64 @@ module Smith
|
|
|
15
15
|
option :profile
|
|
16
16
|
option :decision_recorder
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# Whether #call sends a chat of `profile` with these tools (tools or tool
|
|
19
|
+
# classes; `choice` the chat's forced tool choice) to the Responses
|
|
20
|
+
# API: thinking that needs it, or a tool only it takes, or more tools
|
|
21
|
+
# it takes. Runtime readiness asks this without a chat.
|
|
22
|
+
def self.responses?(profile:, tools:, thinking:, choice: nil)
|
|
23
|
+
return false unless profile.provider == :openai && Smith.config.openai_api_mode == :auto && tools.any?
|
|
24
|
+
return true if thinking && thinking_route?(profile)
|
|
25
|
+
|
|
26
|
+
forced = forced_endpoint(tools, choice, profile)
|
|
27
|
+
return forced == :responses if forced
|
|
28
|
+
|
|
29
|
+
compatible_count(tools, :responses, profile) > compatible_count(tools, :chat_completions, profile)
|
|
30
|
+
end
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
32
|
+
def self.thinking_route?(profile)
|
|
33
|
+
!profile.tools_with_thinking_native && profile.tools_with_thinking_route == :responses
|
|
34
|
+
end
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
# The one endpoint that can carry a specifically forced tool.
|
|
37
|
+
def self.forced_endpoint(tools, choice, profile)
|
|
38
|
+
tool = forced_tool(tools, choice&.to_sym)
|
|
39
|
+
return unless tool
|
|
40
|
+
|
|
41
|
+
endpoints = TOOL_ENDPOINTS.select { |endpoint| compatible?(tool, endpoint, profile) }
|
|
42
|
+
endpoints.first if endpoints.one?
|
|
27
43
|
end
|
|
28
44
|
|
|
29
|
-
|
|
45
|
+
def self.forced_tool(tools, choice)
|
|
46
|
+
return if choice.nil? || %i[auto none required].include?(choice)
|
|
47
|
+
|
|
48
|
+
tools.find { |candidate| !candidate.is_a?(Class) && candidate.name.to_sym == choice }
|
|
49
|
+
end
|
|
30
50
|
|
|
31
|
-
def
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return unless
|
|
35
|
-
return unless Smith.config.openai_api_mode == :auto
|
|
51
|
+
def self.compatible_count(tools, endpoint, profile) = tools.count { compatible?(_1, endpoint, profile) }
|
|
52
|
+
|
|
53
|
+
def self.compatible?(tool, endpoint, profile)
|
|
54
|
+
return true unless defined?(Smith::Tool::Compatibility)
|
|
36
55
|
|
|
37
|
-
|
|
56
|
+
tool_class = tool.is_a?(Class) ? tool : tool.class
|
|
57
|
+
spec = tool_class.respond_to?(:compatible_with_spec) ? tool_class.compatible_with_spec : nil
|
|
58
|
+
Smith::Tool::Compatibility.allows?(spec, profile, effective_endpoint: endpoint)
|
|
38
59
|
end
|
|
60
|
+
private_class_method :thinking_route?, :forced_endpoint, :forced_tool, :compatible_count, :compatible?
|
|
39
61
|
|
|
40
|
-
def
|
|
41
|
-
return unless
|
|
42
|
-
return unless Smith.config.openai_api_mode == :auto
|
|
43
|
-
return if responses_routed?
|
|
62
|
+
def call
|
|
63
|
+
return unless chat.respond_to?(:tools)
|
|
44
64
|
|
|
45
|
-
|
|
46
|
-
return if
|
|
47
|
-
return unless forced_endpoint == :responses ||
|
|
48
|
-
compatible_tool_count(tools, :responses) > compatible_tool_count(tools, :chat_completions)
|
|
65
|
+
tools = chat.tools.values
|
|
66
|
+
return if tools.empty?
|
|
49
67
|
|
|
50
|
-
|
|
68
|
+
if self.class.responses?(profile:, tools:, thinking: thinking_active?, choice: chat.tool_prefs[:choice])
|
|
69
|
+
route_via_responses
|
|
70
|
+
end
|
|
71
|
+
drop_incompatible_tools(tools)
|
|
51
72
|
end
|
|
52
73
|
|
|
74
|
+
private
|
|
75
|
+
|
|
53
76
|
def route_via_responses
|
|
54
77
|
return if responses_routed?
|
|
55
78
|
|
|
@@ -78,17 +101,6 @@ module Smith
|
|
|
78
101
|
detail
|
|
79
102
|
end
|
|
80
103
|
|
|
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
104
|
def reset_invalid_tool_choice(remaining)
|
|
93
105
|
choice = chat.tool_prefs[:choice]
|
|
94
106
|
return unless choice
|
|
@@ -100,16 +112,7 @@ module Smith
|
|
|
100
112
|
choice
|
|
101
113
|
end
|
|
102
114
|
|
|
103
|
-
def
|
|
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
|
|
115
|
+
def tool_compatible_with?(tool, endpoint) = self.class.__send__(:compatible?, tool, endpoint, profile)
|
|
113
116
|
|
|
114
117
|
def thinking_active?
|
|
115
118
|
thinking = chat.instance_variable_get(:@thinking)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "diagnostic_text"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
# An agent's response that cannot be its step's output: cut off at its
|
|
8
|
+
# output limit (OutputTruncated), refused (OutputRefused), or not the
|
|
9
|
+
# structured output the agent declares (OutputInvalid). The response was
|
|
10
|
+
# billed all the same. Not an AgentError and not retryable: the agent's
|
|
11
|
+
# own retry, fallback and repairs have run, and running the same request
|
|
12
|
+
# again would repeat the cost. Its failure family is `output_failure`;
|
|
13
|
+
# details are bounded and persist with a failed step, so a restored run
|
|
14
|
+
# raises the same error.
|
|
15
|
+
class OutputFailure < Error
|
|
16
|
+
DETAIL_NAMES = %i[agent_name model_used stop_reason problems].freeze
|
|
17
|
+
DETAIL_KEYS = DETAIL_NAMES.to_h { |name| [name.to_s.freeze, name] }.freeze
|
|
18
|
+
MAX_TEXT_BYTES = 256
|
|
19
|
+
MAX_PROBLEMS = 20
|
|
20
|
+
private_constant :DETAIL_NAMES, :DETAIL_KEYS
|
|
21
|
+
|
|
22
|
+
attr_reader :agent_name, :model_used, :stop_reason, :problems
|
|
23
|
+
|
|
24
|
+
# `message` is the persisted one when the error is restored.
|
|
25
|
+
def initialize(agent_name:, model_used:, stop_reason: nil, problems: [], message: nil)
|
|
26
|
+
@agent_name = agent_name
|
|
27
|
+
@model_used = model_used
|
|
28
|
+
@stop_reason = stop_reason
|
|
29
|
+
@problems = Array(problems).first(MAX_PROBLEMS).map { |problem| self.class.bounded(problem.to_s) }.freeze
|
|
30
|
+
super(message || describe)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def details
|
|
34
|
+
texts = %i[agent_name model_used stop_reason].to_h { |name| [name, text_detail(public_send(name))] }
|
|
35
|
+
texts.merge(problems:).freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Each output failure class's name and its restore from persisted
|
|
40
|
+
# details, for the failure record validator and reconstructor.
|
|
41
|
+
def restorers
|
|
42
|
+
[OutputFailure, OutputTruncated, OutputRefused, OutputInvalid].to_h do |error_class|
|
|
43
|
+
[error_class.name, ->(details, message = nil) { error_class.from_details(details, message:) }]
|
|
44
|
+
end.freeze
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def from_details(details, message: nil)
|
|
48
|
+
values = normalize_details(details)
|
|
49
|
+
new(agent_name: values.fetch(:agent_name)&.to_sym, model_used: values.fetch(:model_used),
|
|
50
|
+
stop_reason: values.fetch(:stop_reason), problems: values.fetch(:problems), message:)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def bounded(text) = DiagnosticText.capture(text, max_bytes: MAX_TEXT_BYTES)
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def normalize_details(details)
|
|
58
|
+
raise ArgumentError, "output failure details must be a Hash" unless details.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
values = {}
|
|
61
|
+
Hash.instance_method(:each_pair).bind_call(details) do |key, value|
|
|
62
|
+
name = detail_name(key, values)
|
|
63
|
+
values[name] = name == :problems ? problem_list(value) : bounded_text(name, value)
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, "output failure details are incomplete" unless values.length == DETAIL_NAMES.length
|
|
66
|
+
|
|
67
|
+
values
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def detail_name(key, seen)
|
|
71
|
+
name = key.is_a?(Symbol) ? key : DETAIL_KEYS[key]
|
|
72
|
+
raise ArgumentError, "output failure details contain an unknown attribute" unless DETAIL_NAMES.include?(name)
|
|
73
|
+
raise ArgumentError, "output failure details contain a duplicate attribute" if seen.key?(name)
|
|
74
|
+
|
|
75
|
+
name
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def bounded_text(name, value)
|
|
79
|
+
return value if value.nil?
|
|
80
|
+
return value if value.is_a?(String) && value.valid_encoding? && value.bytesize <= MAX_TEXT_BYTES
|
|
81
|
+
|
|
82
|
+
raise ArgumentError, "output failure detail #{name} must be bounded text"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def problem_list(value)
|
|
86
|
+
unless value.is_a?(Array) && value.length <= MAX_PROBLEMS
|
|
87
|
+
raise ArgumentError, "output failure problems must be a list of at most #{MAX_PROBLEMS}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
value.map { |problem| bounded_text(:problems, problem) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def text_detail(value) = value && self.class.bounded(value.to_s)
|
|
97
|
+
|
|
98
|
+
def outcome = "could not be used"
|
|
99
|
+
|
|
100
|
+
def describe
|
|
101
|
+
[
|
|
102
|
+
"agent#{" :#{agent_name}" if agent_name} response#{" from model #{model_used}" if model_used} #{outcome}",
|
|
103
|
+
(" (stop reason #{stop_reason})" if stop_reason),
|
|
104
|
+
(": #{problems.first(3).join("; ")}" unless problems.empty?)
|
|
105
|
+
].join
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The response stopped at its output limit.
|
|
110
|
+
class OutputTruncated < OutputFailure
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def outcome = "was cut off at its output limit"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The provider or model declined to answer.
|
|
117
|
+
class OutputRefused < OutputFailure
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def outcome = "was refused"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The output is not a JSON object, or breaks the agent's output contract
|
|
124
|
+
# or a schema constraint Smith checks itself.
|
|
125
|
+
class OutputInvalid < OutputFailure
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def outcome = "is invalid"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -3,12 +3,16 @@
|
|
|
3
3
|
module Smith
|
|
4
4
|
module PersistenceAdapters
|
|
5
5
|
module ActiveRecordExactStore
|
|
6
|
+
# Child runs are stored under 64-character keys (digests).
|
|
7
|
+
KEY_WIDTH = 64
|
|
8
|
+
|
|
6
9
|
attr_reader :persistence_identity
|
|
7
10
|
|
|
8
11
|
def replace_exact(key, payload, expected_payload:, ttl: nil)
|
|
9
12
|
ensure_versioned_write!(ttl)
|
|
10
13
|
model = model_class
|
|
11
14
|
ensure_exact_schema!(model)
|
|
15
|
+
ensure_plain_payload!(model)
|
|
12
16
|
ActiveRecordExactWrite.new(
|
|
13
17
|
model: model,
|
|
14
18
|
key_column: @key_column,
|
|
@@ -22,6 +26,28 @@ module Smith
|
|
|
22
26
|
raise Smith::PersistenceIOError.new(operation: :replace_exact, cause: e)
|
|
23
27
|
end
|
|
24
28
|
|
|
29
|
+
# Raises ArgumentError unless the model's table holds runs as their
|
|
30
|
+
# writes need: the locking column, a text payload, and a string key the
|
|
31
|
+
# database keeps unique, wide enough for child runs' keys (smith:doctor
|
|
32
|
+
# reports it). Returns self.
|
|
33
|
+
def check_schema!
|
|
34
|
+
model = model_class
|
|
35
|
+
ensure_version_column!
|
|
36
|
+
ensure_locking_configuration!
|
|
37
|
+
ensure_exact_schema!(model)
|
|
38
|
+
ensure_key_column!(model)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether Rails encrypts the payload (`encrypts`): runs store and read
|
|
43
|
+
# it, but replace_exact, which compares payloads in the database,
|
|
44
|
+
# cannot.
|
|
45
|
+
def encrypted_payload?
|
|
46
|
+
model = model_class
|
|
47
|
+
attributes = model.encrypted_attributes if model.respond_to?(:encrypted_attributes)
|
|
48
|
+
Array(attributes).map(&:to_s).include?(@payload_column.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
25
51
|
private
|
|
26
52
|
|
|
27
53
|
def ensure_versioned_write!(ttl)
|
|
@@ -44,6 +70,23 @@ module Smith
|
|
|
44
70
|
"#{model.table_name}.#{@payload_column}"
|
|
45
71
|
end
|
|
46
72
|
|
|
73
|
+
def ensure_key_column!(model)
|
|
74
|
+
column = model.columns_hash.fetch(@key_column.to_s)
|
|
75
|
+
return if %i[string text].include?(column.type) && (column.limit.nil? || column.limit >= KEY_WIDTH)
|
|
76
|
+
|
|
77
|
+
raise ArgumentError,
|
|
78
|
+
"ActiveRecordStore requires #{model.table_name}.#{@key_column} to be a string or text column of at " \
|
|
79
|
+
"least #{KEY_WIDTH} characters: child runs are stored under #{KEY_WIDTH}-character keys"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ensure_plain_payload!(model)
|
|
83
|
+
return unless encrypted_payload?
|
|
84
|
+
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"ActiveRecordStore#replace_exact compares payloads in the database, so it cannot use " \
|
|
87
|
+
"#{model.name}'s encrypted #{@payload_column}"
|
|
88
|
+
end
|
|
89
|
+
|
|
47
90
|
def ensure_exact_key_uniqueness!(model)
|
|
48
91
|
connection = model.connection
|
|
49
92
|
schema_cache = connection.schema_cache
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Pricing
|
|
5
|
+
# Admission-time catalog validation, the raising surface for the rules
|
|
6
|
+
# `compute_cost` applies without raising. See Pricing.validate_catalog!.
|
|
7
|
+
module CatalogValidation
|
|
8
|
+
OPTIONAL_RATE_KEYS = %i[cache_read_cost_per_token cache_write_cost_per_token].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def validate!(catalog)
|
|
13
|
+
return catalog if catalog.nil?
|
|
14
|
+
raise Smith::PricingConfigurationError, "pricing catalog must be a Hash" unless catalog.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
catalog.each_with_object({}) do |(key, entry), seen|
|
|
17
|
+
provider, model = validated_key!(key)
|
|
18
|
+
validate_entry!(key, entry)
|
|
19
|
+
reject_collision!(seen, key, provider, model)
|
|
20
|
+
end
|
|
21
|
+
catalog
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def validated_key!(key)
|
|
25
|
+
provider, model = Pricing.__send__(:canonical_key, key)
|
|
26
|
+
raise Smith::PricingConfigurationError, "unrecognized pricing catalog key #{key.inspect}" unless model
|
|
27
|
+
return [provider, model] if provider
|
|
28
|
+
|
|
29
|
+
raise Smith::PricingConfigurationError, "pricing for #{model} must use a provider-qualified catalog key"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def validate_entry!(key, entry)
|
|
33
|
+
unless entry.is_a?(Hash)
|
|
34
|
+
raise Smith::PricingConfigurationError, "pricing entry for #{key.inspect} must be a Hash"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
tiers = Pricing.__send__(:value_at, entry, :tiers)
|
|
38
|
+
tiers = [] unless tiers.is_a?(Array)
|
|
39
|
+
[entry, *tiers].each { |rates| validate_optional_rates!(key, rates) }
|
|
40
|
+
values_at(entry, :batch_discount).each { |discount| validate_batch_discount!(key, discount) }
|
|
41
|
+
reject_tier_batch_discount!(key, tiers)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_optional_rates!(key, rates)
|
|
45
|
+
return unless rates.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
OPTIONAL_RATE_KEYS.each do |name|
|
|
48
|
+
next if values_at(rates, name).all? { |rate| non_negative_number?(rate) }
|
|
49
|
+
|
|
50
|
+
raise Smith::PricingConfigurationError, "#{name} for #{key.inspect} must be a non-negative Integer or Float"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Every form of a key the entry carries, Symbol and String, so a
|
|
55
|
+
# malformed value cannot hide behind a valid one under the other form.
|
|
56
|
+
def values_at(source, name)
|
|
57
|
+
[name, name.to_s].select { |form| source.key?(form) }.map { |form| source[form] }.compact
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_batch_discount!(key, discount)
|
|
61
|
+
return if non_negative_number?(discount) && discount < 1
|
|
62
|
+
|
|
63
|
+
raise Smith::PricingConfigurationError, "batch_discount for #{key.inspect} must be at least 0 and below 1"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A batch discount applies to a whole call, so it belongs on the entry;
|
|
67
|
+
# one inside a tier would never be read.
|
|
68
|
+
def reject_tier_batch_discount!(key, tiers)
|
|
69
|
+
return unless tiers.any? { |tier| tier.is_a?(Hash) && values_at(tier, :batch_discount).any? }
|
|
70
|
+
|
|
71
|
+
raise Smith::PricingConfigurationError, "batch_discount for #{key.inspect} belongs on the entry, not a tier"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def non_negative_number?(value)
|
|
75
|
+
(value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value >= 0
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reject_collision!(seen, key, provider, model)
|
|
79
|
+
previous = seen[[provider, model]]
|
|
80
|
+
if previous
|
|
81
|
+
raise Smith::PricingConfigurationError,
|
|
82
|
+
"pricing catalog keys #{previous.inspect} and #{key.inspect} collide as #{provider}/#{model}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
seen[[provider, model]] = key
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private_constant :CatalogValidation
|
|
90
|
+
end
|
|
91
|
+
end
|