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
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "provider_failure_handling"
|
|
4
|
+
require_relative "content_bytes"
|
|
5
|
+
require_relative "content_parts"
|
|
4
6
|
require_relative "invocation_preparation"
|
|
5
7
|
require_relative "provider_attempt"
|
|
6
8
|
require_relative "provider_call_timing"
|
|
9
|
+
require_relative "provider_request"
|
|
7
10
|
require_relative "provider_candidate_sequence"
|
|
11
|
+
require_relative "provider_streaming"
|
|
12
|
+
require_relative "observed_model"
|
|
8
13
|
|
|
9
14
|
module Smith
|
|
10
15
|
class Agent
|
|
11
16
|
module ProviderCompletion
|
|
12
17
|
include ProviderFailureHandling
|
|
13
18
|
include InvocationPreparation
|
|
19
|
+
include ContentParts
|
|
14
20
|
include ProviderCallTiming
|
|
21
|
+
include ProviderRequest
|
|
22
|
+
include ProviderStreaming
|
|
23
|
+
include ObservedModel
|
|
15
24
|
|
|
16
25
|
private
|
|
17
26
|
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
# The input's documents and images are read once (ContentBytes), and
|
|
28
|
+
# its cached parts checked against every Anthropic model in the chain,
|
|
29
|
+
# before the first attempt.
|
|
30
|
+
def complete_with_provider(agent_class, prepared_input, shape:)
|
|
31
|
+
chain = build_model_chain(agent_class).tap { check_chain_cache_breakpoints!(_1, prepared_input) }
|
|
32
|
+
ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def attempt_candidates(agent_class, prepared_input, chain, shape)
|
|
36
|
+
candidates = ProviderCandidateSequence.new(chain)
|
|
20
37
|
candidates.each do |model_reference, index|
|
|
21
38
|
check_deadline! if index.positive?
|
|
22
|
-
|
|
39
|
+
withdraw_streamed(index.positive? ? :fallback : :retry)
|
|
40
|
+
attempt = attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index: index)
|
|
23
41
|
return attempt if attempt.success?
|
|
24
42
|
|
|
25
43
|
candidates.suppress(account_failed_provider(attempt, model_reference))
|
|
@@ -33,7 +51,7 @@ module Smith
|
|
|
33
51
|
# Fail closed: an empty candidate chain (agent without a model)
|
|
34
52
|
# or any exhausted sequence must surface a typed diagnostic, not
|
|
35
53
|
# the sequence itself destructured into nils.
|
|
36
|
-
raise Smith::
|
|
54
|
+
raise Smith::WorkflowError,
|
|
37
55
|
"no executable model candidate for #{agent_class}; declare a model or fallback_models"
|
|
38
56
|
end
|
|
39
57
|
|
|
@@ -88,52 +106,37 @@ module Smith
|
|
|
88
106
|
# cohesive lifecycle (identity, prepared chat, observed model, timed
|
|
89
107
|
# completion, prefix accounting on failure); splitting it would scatter
|
|
90
108
|
# the rescue-path accounting away from what it accounts for.
|
|
91
|
-
def attempt_model(agent_class, prepared_input, model_reference,
|
|
109
|
+
def attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index:)
|
|
92
110
|
facts = { attempt_id: SecureRandom.uuid, attempt_index:, agent_name: agent_class.register_as }
|
|
93
|
-
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference,
|
|
111
|
+
chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
|
|
94
112
|
message_count = chat_message_count(chat)
|
|
95
113
|
observed_reference = observed_model_reference(chat, fallback: model_reference)
|
|
96
114
|
timer = ProviderCallTiming::Timer.start
|
|
97
|
-
response = chat
|
|
115
|
+
response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
|
|
98
116
|
timer.stop
|
|
99
|
-
completion = Completion.from_messages(response
|
|
117
|
+
completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
|
|
118
|
+
mode:)
|
|
100
119
|
|
|
101
|
-
completed_provider_attempt(completion, observed_reference, timer, facts)
|
|
120
|
+
completed_provider_attempt(completion, observed_reference, timer, facts.merge(call_facts.to_h))
|
|
102
121
|
rescue StandardError => e
|
|
103
122
|
timer&.stop
|
|
104
123
|
observed_reference ||= observed_model_reference(chat, fallback: model_reference)
|
|
105
124
|
prefix_usages = account_completed_prefix(
|
|
106
|
-
agent_class, observed_reference, new_chat_messages(chat, message_count),
|
|
125
|
+
agent_class, observed_reference, new_chat_messages(chat, message_count),
|
|
126
|
+
attempt_id: facts[:attempt_id], stream_rounds: streamed_rounds(collected || [])
|
|
107
127
|
)
|
|
108
128
|
attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
|
|
109
|
-
raise unless provider_failure?(e)
|
|
129
|
+
raise unless provider_failure?(e) || unsupported_content?(e)
|
|
110
130
|
|
|
111
131
|
attempt
|
|
112
132
|
end
|
|
113
133
|
# rubocop:enable Metrics/AbcSize
|
|
114
134
|
|
|
115
|
-
def
|
|
116
|
-
model = observable_model(chat)
|
|
117
|
-
return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
|
|
118
|
-
|
|
119
|
-
ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
|
|
120
|
-
rescue StandardError
|
|
121
|
-
fallback
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def observable_model(chat)
|
|
125
|
-
resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
126
|
-
resolved_chat.model if resolved_chat.respond_to?(:model)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def observed_provider(model, fallback)
|
|
130
|
-
model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
def prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
|
|
135
|
+
def prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
|
|
134
136
|
chat = agent_class.chat(**model_reference.chat_options, **bridge_workflow_inputs(agent_class))
|
|
135
|
-
add_prepared_input(chat, prepared_input)
|
|
136
|
-
|
|
137
|
+
add_prepared_input(chat, prepared_input, observed_model_reference(chat, fallback: model_reference).provider)
|
|
138
|
+
continue_paused_loop(chat)
|
|
139
|
+
shaped_chat(chat, model_reference, shape)
|
|
137
140
|
end
|
|
138
141
|
|
|
139
142
|
def chat_message_count(chat)
|
|
@@ -49,7 +49,8 @@ module Smith
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def fallback_eligible?(error)
|
|
52
|
-
|
|
52
|
+
unsupported_content?(error) ||
|
|
53
|
+
transient_failure?(error) ||
|
|
53
54
|
transport_failure?(error) ||
|
|
54
55
|
provider_account_failure?(error) ||
|
|
55
56
|
model_permission_failure?(error) ||
|
|
@@ -80,6 +81,9 @@ module Smith
|
|
|
80
81
|
|
|
81
82
|
def terminal_provider_error(error, model_reference)
|
|
82
83
|
return Smith::AgentError.new(error.message) if transient_failure?(error) || transport_failure?(error)
|
|
84
|
+
if unsupported_content?(error)
|
|
85
|
+
return Smith::ContentUnavailable.new("#{model_reference} takes none of this content: #{error.message}")
|
|
86
|
+
end
|
|
83
87
|
|
|
84
88
|
Smith::ProviderPermanentFailure.new(
|
|
85
89
|
error.message,
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# What one provider call asks for beyond its messages: the output schema
|
|
6
|
+
# as the provider takes it, and a larger output limit when a cut-off
|
|
7
|
+
# response is retried.
|
|
8
|
+
module ProviderRequest
|
|
9
|
+
Shape = Data.define(:output_schema, :output_limit)
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def shaped_chat(chat, model_reference, shape)
|
|
14
|
+
provider = observed_model_reference(chat, fallback: model_reference).provider
|
|
15
|
+
chat = with_output_limit(chat, provider, shape.output_limit) if shape.output_limit
|
|
16
|
+
shape.output_schema ? chat.with_schema(provider_output_schema(shape.output_schema, provider)) : chat
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def with_output_limit(chat, provider, limit)
|
|
20
|
+
return chat unless chat.respond_to?(:with_params)
|
|
21
|
+
|
|
22
|
+
params = (chat.params if chat.respond_to?(:params)) || {}
|
|
23
|
+
chat.with_params(**::RubyLLM::Utils.deep_merge(params, Providers::OutputLimit.params(provider, params, limit)))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# For Anthropic without the constraints it rejects (SchemaConstraints);
|
|
27
|
+
# unchanged otherwise.
|
|
28
|
+
def provider_output_schema(output_schema, provider)
|
|
29
|
+
return output_schema unless provider.to_s == "anthropic"
|
|
30
|
+
|
|
31
|
+
payload = Providers::SchemaConstraints.json_schema(output_schema)
|
|
32
|
+
payload ? Providers::SchemaConstraints.for_provider(payload, provider) : output_schema
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Agent
|
|
5
|
+
# The provider calls of a streamed step (Smith::Streaming): before a
|
|
6
|
+
# call that replaces an earlier one, the earlier call's text is
|
|
7
|
+
# withdrawn; each call is announced and its text chunks sent. The stop
|
|
8
|
+
# reason is read from the stream (Providers::StreamFacts), as a
|
|
9
|
+
# streamed reply keeps no raw body.
|
|
10
|
+
module ProviderStreaming
|
|
11
|
+
# Room for what a pause adds besides its messages (its retry, the
|
|
12
|
+
# attempt's usage).
|
|
13
|
+
PAUSE_MARGIN = 4096
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# Withdraws the text a streamed step sent, for `reason`, when a call
|
|
18
|
+
# is about to replace it.
|
|
19
|
+
def withdraw_streamed(reason) = @step_stream&.withdraw(reason)
|
|
20
|
+
|
|
21
|
+
# The chat's reply and, for a streamed step, what Completion needs to
|
|
22
|
+
# read its facts. The stream's collection goes into `collected` too,
|
|
23
|
+
# for the facts of a call that fails part way. Requests over the wire
|
|
24
|
+
# are held on the step's budget ledger, if it has one, keyed by the
|
|
25
|
+
# attempt's `attempt_id` (`facts`).
|
|
26
|
+
def complete_chat(chat, model_reference, collected, facts = {})
|
|
27
|
+
return capture_batch_call(chat, model_reference) if @batch_capturing
|
|
28
|
+
|
|
29
|
+
entry = take_batch_result
|
|
30
|
+
delivered = deliver_batch_result(chat, entry) if entry
|
|
31
|
+
return delivered if delivered
|
|
32
|
+
|
|
33
|
+
remembered_completion(chat, model_reference, facts) do |resumed|
|
|
34
|
+
live_completion(chat, model_reference, collected, facts, resumed)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The call over the wire; `resumed` (RememberedCalls) says where a
|
|
39
|
+
# call a response memo kept part of goes on from.
|
|
40
|
+
def live_completion(chat, model_reference, collected, facts, resumed)
|
|
41
|
+
ledger = run_budget_ledger
|
|
42
|
+
return wire_completion(chat, model_reference, collected) unless ledger
|
|
43
|
+
|
|
44
|
+
start = resumed ? resumed.start : chat_message_count(chat)
|
|
45
|
+
holding = { pausable: budget_pausable?, rounds: resumed&.rounds || 0 }
|
|
46
|
+
Providers::RequestHolds.within(chat_provider(chat), ledger, facts[:attempt_id], **holding) do |holder|
|
|
47
|
+
held_completion(holder, chat, start) { wire_completion(chat, model_reference, collected) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A refusal of a tool loop's later round pauses the loop (its step
|
|
52
|
+
# retries for budget, BudgetPause): the error carries the messages to
|
|
53
|
+
# continue from (from `start`, a memo's kept rounds included). A loop
|
|
54
|
+
# whose messages cannot be kept goes on, unheld, from its tools'
|
|
55
|
+
# results (the chat keeps them), as when its step cannot pause.
|
|
56
|
+
def held_completion(holder, chat, start)
|
|
57
|
+
yield
|
|
58
|
+
rescue BudgetUnavailable => e
|
|
59
|
+
raise unless holder.pausable && holder.rounds > 1 && e.continuation.nil?
|
|
60
|
+
|
|
61
|
+
paused = paused_loop(e, chat, start)
|
|
62
|
+
raise paused if paused
|
|
63
|
+
|
|
64
|
+
holder.pausable = false
|
|
65
|
+
retry
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def paused_loop(error, chat, start)
|
|
69
|
+
continuation = Budget::Continuation.dump(@budget_continuation, chat.messages.drop(start || 0))
|
|
70
|
+
BudgetUnavailable.new(error.message, continuation:) if continuation && storable_pause?(continuation)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Whether the run's state, with the paused loop kept, stays within
|
|
74
|
+
# persistence_max_bytes: a pause it could not store would lose the
|
|
75
|
+
# loop's paid rounds, so the loop goes on instead.
|
|
76
|
+
def storable_pause?(continuation)
|
|
77
|
+
limit = Smith.config.persistence_max_bytes
|
|
78
|
+
return true unless limit
|
|
79
|
+
|
|
80
|
+
JSON.generate(to_state).bytesize + JSON.generate(continuation).bytesize + PAUSE_MARGIN <= limit
|
|
81
|
+
rescue StandardError
|
|
82
|
+
false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def wire_completion(chat, model_reference, collected)
|
|
86
|
+
stream = @step_stream
|
|
87
|
+
return [chat.complete, nil] unless stream
|
|
88
|
+
|
|
89
|
+
stream.start(attempt: @step_attempt, model: model_reference.model_id, provider: model_reference.provider&.to_s)
|
|
90
|
+
response, rounds = streamed_completion(chat, stream, collected)
|
|
91
|
+
[response, { rounds:, output_limit: request_output_limit(chat, model_reference) }]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The chat's reply, its text sent as it comes (without <think> text),
|
|
95
|
+
# and the facts of each of its requests.
|
|
96
|
+
def streamed_completion(chat, stream, collected)
|
|
97
|
+
response = nil
|
|
98
|
+
thinking = Streaming::ThinkTags.new
|
|
99
|
+
rounds = Providers::StreamFacts.collect(chat_provider(chat)) do |collection|
|
|
100
|
+
collected << collection
|
|
101
|
+
response = chat.complete { |chunk| stream.delta(thinking.strip(chunk.content, collection.rounds.length)) }
|
|
102
|
+
end
|
|
103
|
+
[response, rounds]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The batch result as the chat's reply, and whether the request the
|
|
107
|
+
# call rendered is other than the one sent (a fact of its attempt). A
|
|
108
|
+
# result its call will not use (`on_request_change` :resend or :fail)
|
|
109
|
+
# is set aside, to be charged; :resend returns nil, and the call goes
|
|
110
|
+
# over the wire.
|
|
111
|
+
def deliver_batch_result(chat, entry)
|
|
112
|
+
delivery = @batch_delivery
|
|
113
|
+
response, changed = Providers::BatchExchange.deliver(chat_provider(chat), entry.result, entry.digest,
|
|
114
|
+
on_change: delivery.on_change) { chat.complete }
|
|
115
|
+
[response, nil, :batch, { request_changed: changed || nil }]
|
|
116
|
+
rescue BatchRequestChanged
|
|
117
|
+
delivery.set_aside(Attribution.current&.branch_key, entry)
|
|
118
|
+
raise unless delivery.on_change == :resend
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A batch step's call: the provider must have a batch interface (only
|
|
122
|
+
# Anthropic's, so far) and the agent no tools (a batch request is one
|
|
123
|
+
# turn). The request is thrown out of the call before it is sent.
|
|
124
|
+
def capture_batch_call(chat, model_reference)
|
|
125
|
+
provider = chat_provider(chat)
|
|
126
|
+
unless provider.respond_to?(:slug) && provider.slug.to_s == "anthropic"
|
|
127
|
+
raise BatchModeUnavailable, "#{model_reference.provider || "this provider"} has no batch interface in Smith"
|
|
128
|
+
end
|
|
129
|
+
raise BatchModeUnavailable, "a batch request is one turn: its agent cannot have tools" if chat_tools?(chat)
|
|
130
|
+
|
|
131
|
+
@batch_capture = Providers::BatchExchange::Exchange.new(provider:, holding: !run_budget_ledger.nil?)
|
|
132
|
+
Providers::BatchExchange.within(@batch_capture) { chat.complete }
|
|
133
|
+
raise BatchModeUnavailable, "the call did not reach its provider's request"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def chat_tools?(chat)
|
|
137
|
+
tools = chat.tools if chat.respond_to?(:tools)
|
|
138
|
+
tools.respond_to?(:empty?) && !tools.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The facts of a streamed call that failed, if it streamed.
|
|
142
|
+
def streamed_rounds(collected) = collected.first&.facts
|
|
143
|
+
|
|
144
|
+
def chat_provider(chat)
|
|
145
|
+
llm_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
|
|
146
|
+
llm_chat.instance_variable_get(:@provider)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def request_output_limit(chat, model_reference)
|
|
150
|
+
params = chat.params if chat.respond_to?(:params)
|
|
151
|
+
Providers::OutputLimit.current(model_reference.provider, params.is_a?(Hash) ? params : {}, model_reference)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -3,12 +3,33 @@
|
|
|
3
3
|
require "dry-struct"
|
|
4
4
|
|
|
5
5
|
require_relative "../types"
|
|
6
|
+
require_relative "../providers/response_facts"
|
|
6
7
|
|
|
7
8
|
module Smith
|
|
8
9
|
class Agent
|
|
10
|
+
# The usage one provider response reported. `input_tokens` counts only
|
|
11
|
+
# uncached input, which is how RubyLLM reports every provider: tokens read
|
|
12
|
+
# from or written to a prompt cache are `cache_read_tokens` and
|
|
13
|
+
# `cache_write_tokens`. `thinking_tokens` are already inside
|
|
14
|
+
# `output_tokens` and are reported, never added. The optional counts are
|
|
15
|
+
# nil when the provider did not report them.
|
|
9
16
|
class ProviderUsage < Dry::Struct
|
|
17
|
+
COUNT_READERS = {
|
|
18
|
+
cache_read_tokens: %i[cache_read_tokens cached_tokens],
|
|
19
|
+
cache_write_tokens: %i[cache_write_tokens cache_creation_tokens],
|
|
20
|
+
thinking_tokens: %i[thinking_tokens]
|
|
21
|
+
}.freeze
|
|
22
|
+
OPTIONAL_COUNTS = COUNT_READERS.keys.freeze
|
|
23
|
+
|
|
10
24
|
attribute :input_tokens, Types::Integer
|
|
11
25
|
attribute :output_tokens, Types::Integer
|
|
26
|
+
attribute :cache_read_tokens, Types::Integer.optional.default(nil)
|
|
27
|
+
attribute :cache_write_tokens, Types::Integer.optional.default(nil)
|
|
28
|
+
attribute :thinking_tokens, Types::Integer.optional.default(nil)
|
|
29
|
+
attribute :stop_reason, Types::String.optional.default(nil)
|
|
30
|
+
attribute :provider_message_id, Types::String.optional.default(nil)
|
|
31
|
+
# How the response was made: :stream, :batch, or nil (synchronous).
|
|
32
|
+
attribute :mode, Types::Symbol.optional.default(nil)
|
|
12
33
|
|
|
13
34
|
def self.from_message(message)
|
|
14
35
|
return unless message.respond_to?(:input_tokens) && message.respond_to?(:output_tokens)
|
|
@@ -17,17 +38,63 @@ module Smith
|
|
|
17
38
|
output_tokens = message.output_tokens
|
|
18
39
|
return unless input_tokens.is_a?(Integer) && output_tokens.is_a?(Integer)
|
|
19
40
|
|
|
20
|
-
new(input_tokens:, output_tokens
|
|
41
|
+
new(input_tokens:, output_tokens:, **optional_counts(message), **Providers::ResponseFacts.from_message(message))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.optional_counts(message)
|
|
45
|
+
COUNT_READERS.transform_values do |readers|
|
|
46
|
+
readers.lazy.filter_map { |reader| count_from(message, reader) }.first
|
|
47
|
+
end
|
|
21
48
|
end
|
|
22
49
|
|
|
50
|
+
def self.count_from(message, reader)
|
|
51
|
+
return unless message.respond_to?(reader)
|
|
52
|
+
|
|
53
|
+
value = message.public_send(reader)
|
|
54
|
+
value if value.is_a?(Integer) && value >= 0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The stop reason and message id are those of the last response, the
|
|
58
|
+
# one that ended the attempt.
|
|
23
59
|
def self.sum(usages)
|
|
24
60
|
return if usages.empty?
|
|
25
61
|
|
|
26
|
-
|
|
62
|
+
counts = OPTIONAL_COUNTS.to_h do |name|
|
|
63
|
+
values = usages.filter_map(&name)
|
|
64
|
+
[name, values.empty? ? nil : values.sum]
|
|
65
|
+
end
|
|
66
|
+
new(input_tokens: usages.sum(&:input_tokens), output_tokens: usages.sum(&:output_tokens), **counts,
|
|
67
|
+
stop_reason: usages.last.stop_reason, provider_message_id: usages.last.provider_message_id,
|
|
68
|
+
mode: usages.last.mode)
|
|
69
|
+
end
|
|
70
|
+
private_class_method :optional_counts, :count_from
|
|
71
|
+
|
|
72
|
+
# This usage with the stop reason and message id of `facts`, a
|
|
73
|
+
# ResponseFacts result, in place of its own: a tool loop's summed
|
|
74
|
+
# usage carries its final response's facts even when that response
|
|
75
|
+
# reported no usage.
|
|
76
|
+
def with_response_facts(facts)
|
|
77
|
+
self.class.new(**to_h.except(:stop_reason, :provider_message_id), **facts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# This usage as a streamed response's, with the facts its stream
|
|
81
|
+
# carried (Providers::StreamFacts).
|
|
82
|
+
def streamed(facts) = with_response_facts(facts).new(mode: :stream)
|
|
83
|
+
|
|
84
|
+
# Optional members are omitted when nil, so usage without cache or
|
|
85
|
+
# response details keeps its earlier shape.
|
|
86
|
+
def to_h
|
|
87
|
+
super.compact
|
|
88
|
+
end
|
|
89
|
+
alias to_hash to_h
|
|
90
|
+
|
|
91
|
+
# Every input token the provider processed, cached or not.
|
|
92
|
+
def total_input_tokens
|
|
93
|
+
input_tokens + (cache_read_tokens || 0) + (cache_write_tokens || 0)
|
|
27
94
|
end
|
|
28
95
|
|
|
29
96
|
def total_tokens
|
|
30
|
-
|
|
97
|
+
total_input_tokens + output_tokens
|
|
31
98
|
end
|
|
32
99
|
end
|
|
33
100
|
end
|
|
@@ -32,7 +32,9 @@ module Smith
|
|
|
32
32
|
attempt_kind: attempt_kind,
|
|
33
33
|
recorded_at: Time.now.utc.iso8601(6),
|
|
34
34
|
attempt_id: attempt_id,
|
|
35
|
-
**ambient_attribution_fields
|
|
35
|
+
**ambient_attribution_fields,
|
|
36
|
+
**Workflow::AgentResult::USAGE_DETAIL_MEMBERS.to_h { |member| [member, agent_result.public_send(member)] },
|
|
37
|
+
mode: agent_result.mode
|
|
36
38
|
)
|
|
37
39
|
end
|
|
38
40
|
|
|
@@ -52,7 +54,7 @@ module Smith
|
|
|
52
54
|
|
|
53
55
|
def accumulate_usage(agent_result, entry)
|
|
54
56
|
@usage_mutex.synchronize do
|
|
55
|
-
@total_tokens = (@total_tokens || 0) + agent_result.
|
|
57
|
+
@total_tokens = (@total_tokens || 0) + agent_result.total_tokens
|
|
56
58
|
@total_cost = (@total_cost || 0.0) + (agent_result.cost || 0.0)
|
|
57
59
|
@usage_entries << entry
|
|
58
60
|
end
|
|
@@ -16,6 +16,8 @@ module Smith
|
|
|
16
16
|
data: {
|
|
17
17
|
input_tokens: agent_result.input_tokens,
|
|
18
18
|
output_tokens: agent_result.output_tokens,
|
|
19
|
+
cache_read_tokens: agent_result.cache_read_tokens,
|
|
20
|
+
cache_write_tokens: agent_result.cache_write_tokens,
|
|
19
21
|
model: agent_result.model_used,
|
|
20
22
|
provider: agent_result.provider_used
|
|
21
23
|
}.compact
|
|
@@ -30,7 +32,9 @@ module Smith
|
|
|
30
32
|
# traces is not a spend total. Unpriced usage emits nothing, and the
|
|
31
33
|
# caller gates out partially metered or partially priced invocations
|
|
32
34
|
# so an incomplete figure is never presented as the invocation cost.
|
|
33
|
-
|
|
35
|
+
# `cache_unpriced: true` says cache tokens were priced at 0 because the
|
|
36
|
+
# catalog has no rate for them.
|
|
37
|
+
def emit_cost_trace(agent_result, invocation_cost, cache_unpriced: false)
|
|
34
38
|
return if invocation_cost.nil?
|
|
35
39
|
|
|
36
40
|
Smith::Trace.record(
|
|
@@ -40,7 +44,10 @@ module Smith
|
|
|
40
44
|
model: agent_result.model_used,
|
|
41
45
|
provider: agent_result.provider_used,
|
|
42
46
|
input_tokens: agent_result.input_tokens,
|
|
43
|
-
output_tokens: agent_result.output_tokens
|
|
47
|
+
output_tokens: agent_result.output_tokens,
|
|
48
|
+
cache_read_tokens: agent_result.cache_read_tokens,
|
|
49
|
+
cache_write_tokens: agent_result.cache_write_tokens,
|
|
50
|
+
cache_unpriced: (true if cache_unpriced)
|
|
44
51
|
}.compact
|
|
45
52
|
)
|
|
46
53
|
end
|
|
@@ -13,64 +13,70 @@ module Smith
|
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
+
# A failed attempt's own reported usage, cache counts included, is
|
|
17
|
+
# recorded, priced and settled into the budget like any billed
|
|
18
|
+
# response, so its usage entry agrees with its :provider_call trace.
|
|
16
19
|
def account_failed_attempt(error, model_reference, agent_class, attempt_id: nil)
|
|
17
|
-
|
|
20
|
+
usage = ProviderUsage.from_message(error)
|
|
21
|
+
return unless usage
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
output = error.output_tokens
|
|
21
|
-
return unless input.is_a?(Integer) && output.is_a?(Integer)
|
|
22
|
-
|
|
23
|
-
record_failed_usage(agent_class, model_reference, input, output, attempt_id:)
|
|
23
|
+
record_billed_usages(agent_class, [usage], :failed_attempt, model_reference, attempt_id:)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
# The completed rounds of a tool loop whose attempt then failed were
|
|
27
|
+
# billed: they are recorded and settled into the budget like a failed
|
|
28
|
+
# attempt's usage. Returns the provider usages recorded, so the
|
|
29
|
+
# attempt's trace carries the same totals its usage entries hold.
|
|
30
|
+
def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil, stream_rounds: nil)
|
|
31
|
+
stream = stream_rounds && { rounds: stream_rounds, output_limit: nil }
|
|
32
|
+
completion = Completion.from_messages(response: nil, messages:, stream:)
|
|
33
|
+
record_billed_usages(agent_class, completion.provider_usages, :partial_attempt, model_reference, attempt_id:)
|
|
31
34
|
completion.provider_usages
|
|
32
35
|
end
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
cost: cost,
|
|
47
|
-
model_used: model_reference.model_id,
|
|
48
|
-
provider_used: model_reference.provider
|
|
49
|
-
)
|
|
50
|
-
Thread.current[:smith_failed_agent_results] ||= []
|
|
51
|
-
Thread.current[:smith_failed_agent_results] << agent_result
|
|
52
|
-
record_usage(agent_class, agent_result, :failed_attempt, model_reference, attempt_id:)
|
|
37
|
+
# Usage that is billed but is not the invocation's result joins the
|
|
38
|
+
# failed billable attempts, which budget settlement reads beside it.
|
|
39
|
+
def record_billed_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
|
|
40
|
+
results = record_response_usages(agent_class, usages, attempt_kind, model_reference, attempt_id:)
|
|
41
|
+
add_failed_agent_results(results)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A new Array, never the one in place: a step's thread context
|
|
45
|
+
# (ThreadContextSnapshot) puts back the list it started with, which
|
|
46
|
+
# must not carry this step's failed attempts into the next.
|
|
47
|
+
def add_failed_agent_results(results)
|
|
48
|
+
Thread.current[:smith_failed_agent_results] = [*Array(Thread.current[:smith_failed_agent_results]), *results]
|
|
53
49
|
end
|
|
54
50
|
|
|
55
|
-
|
|
51
|
+
# Records and settles one completed attempt's usage and returns its
|
|
52
|
+
# result, whether or not the result becomes the output.
|
|
53
|
+
def account_attempt(agent_class, completion, model_reference, attempt_id: nil)
|
|
56
54
|
model_reference = coerce_model_reference(model_reference)
|
|
57
|
-
agent_result =
|
|
58
|
-
content: completion.content,
|
|
59
|
-
input_tokens: completion.input_tokens,
|
|
60
|
-
output_tokens: completion.output_tokens,
|
|
61
|
-
cost: nil,
|
|
62
|
-
model_used: model_reference.model_id,
|
|
63
|
-
provider_used: model_reference.provider
|
|
64
|
-
)
|
|
55
|
+
agent_result = completion_agent_result(completion, model_reference)
|
|
65
56
|
Thread.current[:smith_last_agent_result] = agent_result
|
|
66
57
|
emit_token_usage(agent_result)
|
|
67
58
|
account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
59
|
+
agent_result
|
|
60
|
+
end
|
|
68
61
|
|
|
69
|
-
|
|
62
|
+
# The output has been through `after_completion` (OutputIntegrity).
|
|
63
|
+
def finalize_output(agent_class, agent_result)
|
|
70
64
|
raise_blank_output!(agent_class, agent_result)
|
|
71
65
|
agent_result
|
|
72
66
|
end
|
|
73
67
|
|
|
68
|
+
def completion_agent_result(completion, model_reference)
|
|
69
|
+
Workflow::AgentResult.new(
|
|
70
|
+
content: completion.content,
|
|
71
|
+
input_tokens: completion.input_tokens,
|
|
72
|
+
output_tokens: completion.output_tokens,
|
|
73
|
+
cost: nil,
|
|
74
|
+
model_used: model_reference.model_id,
|
|
75
|
+
provider_used: model_reference.provider,
|
|
76
|
+
**Workflow::AgentResult.usage_details(completion.usage)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
74
80
|
def raise_blank_output!(agent_class, agent_result)
|
|
75
81
|
return unless blank_agent_output?(agent_result.content)
|
|
76
82
|
|
|
@@ -93,26 +99,33 @@ module Smith
|
|
|
93
99
|
# budget settlement and result surfaces read) and the recorded entries,
|
|
94
100
|
# the budget ledger, and the :cost trace all agree. The trace emits
|
|
95
101
|
# only for fully metered, fully priced invocations, so a partial
|
|
96
|
-
# figure is never presented as the invocation cost; the partial
|
|
97
|
-
# still
|
|
102
|
+
# figure is never presented as the invocation cost; the partial sums
|
|
103
|
+
# of cost and tokens still settle the budget because they are what
|
|
104
|
+
# was verifiably billed.
|
|
98
105
|
def account_completion!(agent_class, completion, model_reference, agent_result, attempt_id)
|
|
99
|
-
invocation_cost, fully_priced = record_completion_usage(
|
|
106
|
+
invocation_cost, fully_priced, results = record_completion_usage(
|
|
100
107
|
agent_class, completion, :completed_attempt, model_reference, attempt_id:
|
|
101
108
|
)
|
|
102
109
|
agent_result.cost = invocation_cost
|
|
103
|
-
|
|
110
|
+
agent_result.metered_tokens = results.sum(&:total_tokens) unless completion.usage_complete
|
|
111
|
+
return unless fully_priced
|
|
112
|
+
|
|
113
|
+
emit_cost_trace(agent_result, invocation_cost, cache_unpriced: results.any?(&:cache_unpriced))
|
|
104
114
|
end
|
|
105
115
|
|
|
116
|
+
# Prices the result and notes whether cache tokens were priced at 0
|
|
117
|
+
# for lack of a rate, which the :cost trace reports.
|
|
106
118
|
def compute_agent_cost(agent_result)
|
|
107
119
|
return unless agent_result.usage_known?
|
|
108
120
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
)
|
|
121
|
+
usage = {
|
|
122
|
+
model: agent_result.model_used, provider: agent_result.provider_used,
|
|
123
|
+
input_tokens: agent_result.input_tokens, output_tokens: agent_result.output_tokens,
|
|
124
|
+
cache_read_tokens: agent_result.cache_read_tokens || 0,
|
|
125
|
+
cache_write_tokens: agent_result.cache_write_tokens || 0
|
|
126
|
+
}
|
|
127
|
+
agent_result.cost = Smith::Pricing.compute_cost(**usage, mode: agent_result.mode || :sync)
|
|
128
|
+
agent_result.cache_unpriced = Smith::Pricing.cache_unpriced?(**usage.except(:output_tokens))
|
|
116
129
|
end
|
|
117
130
|
end
|
|
118
131
|
end
|
data/lib/smith/agent.rb
CHANGED
|
@@ -34,6 +34,9 @@ module Smith
|
|
|
34
34
|
subclass.instance_variable_set(:@fallback_models_block, @fallback_models_block)
|
|
35
35
|
subclass.instance_variable_set(:@model_block, @model_block)
|
|
36
36
|
subclass.instance_variable_set(:@tool_budget_exhaustion, @tool_budget_exhaustion)
|
|
37
|
+
%i[@on_truncation @on_refusal @output_contract @output_repairs].each do |name|
|
|
38
|
+
subclass.instance_variable_set(name, instance_variable_get(name))
|
|
39
|
+
end
|
|
37
40
|
subclass.instance_variable_set(:@execution_identity, nil)
|
|
38
41
|
subclass.instance_variable_set(:@registered_name, nil)
|
|
39
42
|
end
|
|
@@ -148,8 +151,10 @@ require_relative "agent/dynamic_configuration"
|
|
|
148
151
|
require_relative "agent/fallback_configuration"
|
|
149
152
|
require_relative "agent/reserved_input_bridge"
|
|
150
153
|
require_relative "agent/chat_construction"
|
|
154
|
+
require_relative "agent/output_policy"
|
|
151
155
|
|
|
152
156
|
Smith::Agent.extend(Smith::Agent::DynamicConfiguration)
|
|
153
157
|
Smith::Agent.extend(Smith::Agent::FallbackConfiguration)
|
|
154
158
|
Smith::Agent.extend(Smith::Agent::ReservedInputBridge)
|
|
155
159
|
Smith::Agent.extend(Smith::Agent::ChatConstruction)
|
|
160
|
+
Smith::Agent.extend(Smith::Agent::OutputPolicy)
|