smith-agents 0.10.0 → 0.12.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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +901 -0
  3. data/README.md +636 -20
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +100 -14
  6. data/docs/PATTERNS.md +88 -12
  7. data/docs/PERSISTENCE.md +738 -6
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +63 -2
  10. data/lib/generators/smith/install/templates/create_workflow_states.rb.tt +31 -0
  11. data/lib/generators/smith/install/templates/smith.rb.tt +39 -8
  12. data/lib/generators/smith/install/templates/workflow_state.rb.tt +19 -0
  13. data/lib/smith/agent/completion.rb +32 -6
  14. data/lib/smith/agent/completion_usage_recording.rb +23 -14
  15. data/lib/smith/agent/content_bytes.rb +98 -0
  16. data/lib/smith/agent/content_parts.rb +149 -0
  17. data/lib/smith/agent/fallback_configuration.rb +26 -3
  18. data/lib/smith/agent/invocation_preparation.rb +37 -14
  19. data/lib/smith/agent/lifecycle.rb +16 -5
  20. data/lib/smith/agent/observed_model.rb +29 -0
  21. data/lib/smith/agent/output_contract_check.rb +53 -0
  22. data/lib/smith/agent/output_integrity.rb +137 -0
  23. data/lib/smith/agent/output_policy.rb +92 -0
  24. data/lib/smith/agent/output_recovery.rb +129 -0
  25. data/lib/smith/agent/provider_attempt.rb +17 -4
  26. data/lib/smith/agent/provider_call_timing.rb +71 -7
  27. data/lib/smith/agent/provider_completion.rb +41 -38
  28. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  29. data/lib/smith/agent/provider_request.rb +36 -0
  30. data/lib/smith/agent/provider_streaming.rb +155 -0
  31. data/lib/smith/agent/provider_usage.rb +75 -2
  32. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  33. data/lib/smith/agent/usage_traces.rb +9 -2
  34. data/lib/smith/agent/usage_tracking.rb +64 -48
  35. data/lib/smith/agent.rb +14 -1
  36. data/lib/smith/artifacts/active_record_store.rb +79 -0
  37. data/lib/smith/artifacts/file.rb +45 -18
  38. data/lib/smith/artifacts/memory.rb +47 -36
  39. data/lib/smith/artifacts/s3.rb +126 -0
  40. data/lib/smith/artifacts/scoped_store.rb +55 -7
  41. data/lib/smith/artifacts/store_rules.rb +49 -0
  42. data/lib/smith/artifacts.rb +98 -1
  43. data/lib/smith/batch/message_shape.rb +36 -0
  44. data/lib/smith/batch.rb +166 -0
  45. data/lib/smith/budget/amount.rb +25 -0
  46. data/lib/smith/budget/amount_representation.rb +20 -0
  47. data/lib/smith/budget/continuation.rb +153 -0
  48. data/lib/smith/budget/host_ledger.rb +137 -0
  49. data/lib/smith/budget/ledger.rb +10 -4
  50. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  51. data/lib/smith/budget/request_hold.rb +211 -0
  52. data/lib/smith/budget/spent_reservation.rb +18 -0
  53. data/lib/smith/child_run.rb +41 -0
  54. data/lib/smith/child_runs_failed.rb +56 -0
  55. data/lib/smith/content.rb +96 -0
  56. data/lib/smith/context/session.rb +14 -2
  57. data/lib/smith/context/state_injection.rb +21 -6
  58. data/lib/smith/context.rb +28 -0
  59. data/lib/smith/diagnostic_text.rb +10 -1
  60. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  61. data/lib/smith/doctor/checks/configuration.rb +0 -11
  62. data/lib/smith/doctor/checks/durability.rb +35 -17
  63. data/lib/smith/doctor/checks/persistence_capabilities.rb +13 -7
  64. data/lib/smith/doctor/checks/state_model.rb +63 -0
  65. data/lib/smith/doctor/installer.rb +9 -2
  66. data/lib/smith/doctor.rb +4 -0
  67. data/lib/smith/errors.rb +304 -6
  68. data/lib/smith/idempotency_key/canonical.rb +74 -0
  69. data/lib/smith/idempotency_key.rb +145 -0
  70. data/lib/smith/launchers/inline.rb +46 -0
  71. data/lib/smith/message.rb +76 -0
  72. data/lib/smith/models/tool_routing.rb +47 -44
  73. data/lib/smith/output_failure.rb +130 -0
  74. data/lib/smith/persistence_adapters/active_record_exact_store.rb +51 -3
  75. data/lib/smith/persistence_adapters/active_record_store.rb +4 -7
  76. data/lib/smith/persistence_adapters.rb +10 -0
  77. data/lib/smith/pricing/catalog_validation.rb +91 -0
  78. data/lib/smith/pricing.rb +95 -64
  79. data/lib/smith/provider_permanent_failure.rb +44 -0
  80. data/lib/smith/providers/anthropic/batches.rb +116 -0
  81. data/lib/smith/providers/batch_exchange.rb +77 -0
  82. data/lib/smith/providers/openai/responses.rb +45 -20
  83. data/lib/smith/providers/output_limit.rb +68 -0
  84. data/lib/smith/providers/request_holds.rb +139 -0
  85. data/lib/smith/providers/response_facts.rb +108 -0
  86. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  87. data/lib/smith/providers/schema_constraints.rb +87 -0
  88. data/lib/smith/providers/schema_rules.rb +66 -0
  89. data/lib/smith/providers/stop_reason.rb +39 -0
  90. data/lib/smith/providers/stream_facts.rb +192 -0
  91. data/lib/smith/response_memo.rb +152 -0
  92. data/lib/smith/setting_constructors.rb +47 -0
  93. data/lib/smith/streaming/json_field.rb +136 -0
  94. data/lib/smith/streaming/json_string.rb +91 -0
  95. data/lib/smith/streaming/session.rb +77 -0
  96. data/lib/smith/streaming/think_tags.rb +67 -0
  97. data/lib/smith/streaming.rb +64 -0
  98. data/lib/smith/testing/checks.rb +58 -0
  99. data/lib/smith/testing/hooks.rb +85 -0
  100. data/lib/smith/testing/minitest.rb +37 -0
  101. data/lib/smith/testing/rspec.rb +52 -0
  102. data/lib/smith/testing/stub.rb +110 -0
  103. data/lib/smith/testing.rb +158 -0
  104. data/lib/smith/tool/scoped_context.rb +9 -12
  105. data/lib/smith/tool.rb +14 -0
  106. data/lib/smith/trace/logger.rb +1 -1
  107. data/lib/smith/trace/memory.rb +1 -1
  108. data/lib/smith/trace/open_telemetry.rb +1 -1
  109. data/lib/smith/trace.rb +11 -4
  110. data/lib/smith/untrusted.rb +74 -0
  111. data/lib/smith/version.rb +2 -2
  112. data/lib/smith/workflow/active_clock.rb +61 -0
  113. data/lib/smith/workflow/agent_result.rb +37 -0
  114. data/lib/smith/workflow/artifact_integration.rb +57 -7
  115. data/lib/smith/workflow/await_step.rb +42 -0
  116. data/lib/smith/workflow/batch_charges.rb +74 -0
  117. data/lib/smith/workflow/batch_execution.rb +149 -0
  118. data/lib/smith/workflow/batch_holds.rb +73 -0
  119. data/lib/smith/workflow/batch_wait.rb +134 -0
  120. data/lib/smith/workflow/budget_integration.rb +14 -8
  121. data/lib/smith/workflow/budget_pause.rb +51 -0
  122. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  123. data/lib/smith/workflow/child_run_execution.rb +150 -0
  124. data/lib/smith/workflow/child_run_results.rb +55 -0
  125. data/lib/smith/workflow/composite/effects.rb +37 -6
  126. data/lib/smith/workflow/composite/error.rb +1 -1
  127. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  128. data/lib/smith/workflow/composite/input.rb +11 -4
  129. data/lib/smith/workflow/context_keys.rb +77 -0
  130. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  131. data/lib/smith/workflow/deterministic_execution.rb +62 -11
  132. data/lib/smith/workflow/deterministic_step.rb +56 -7
  133. data/lib/smith/workflow/dsl.rb +74 -5
  134. data/lib/smith/workflow/durability.rb +140 -31
  135. data/lib/smith/workflow/evaluator_optimizer.rb +116 -12
  136. data/lib/smith/workflow/event_integration.rb +9 -0
  137. data/lib/smith/workflow/execution.rb +32 -23
  138. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  139. data/lib/smith/workflow/failure_reconstructor.rb +41 -2
  140. data/lib/smith/workflow/failure_record.rb +12 -12
  141. data/lib/smith/workflow/failure_record_validator.rb +32 -5
  142. data/lib/smith/workflow/fanout_execution.rb +5 -8
  143. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  144. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  145. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  146. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  147. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  148. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  149. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  150. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  151. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  152. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  153. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  154. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  155. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  156. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  157. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  158. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  159. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  160. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  161. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  162. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  163. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  164. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  165. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  166. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  167. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  168. data/lib/smith/workflow/graph.rb +12 -0
  169. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  170. data/lib/smith/workflow/guardrail_integration.rb +2 -0
  171. data/lib/smith/workflow/message_batch.rb +6 -0
  172. data/lib/smith/workflow/message_roles.rb +25 -0
  173. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  174. data/lib/smith/workflow/nested_execution.rb +47 -4
  175. data/lib/smith/workflow/optimization_state.rb +2 -2
  176. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  177. data/lib/smith/workflow/parallel_execution.rb +10 -0
  178. data/lib/smith/workflow/persistence.rb +162 -22
  179. data/lib/smith/workflow/remembered_calls.rb +92 -0
  180. data/lib/smith/workflow/retry_execution.rb +71 -8
  181. data/lib/smith/workflow/retry_wait.rb +134 -0
  182. data/lib/smith/workflow/run_result.rb +10 -0
  183. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  184. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  185. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  186. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  187. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  188. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  189. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  190. data/lib/smith/workflow/step_completion.rb +40 -7
  191. data/lib/smith/workflow/step_context.rb +21 -0
  192. data/lib/smith/workflow/step_io.rb +132 -0
  193. data/lib/smith/workflow/step_stream.rb +52 -0
  194. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  195. data/lib/smith/workflow/transition.rb +267 -13
  196. data/lib/smith/workflow/transition_actionability.rb +2 -0
  197. data/lib/smith/workflow/usage_entry.rb +24 -3
  198. data/lib/smith/workflow/wait_completion.rb +131 -0
  199. data/lib/smith/workflow/wait_execution.rb +180 -0
  200. data/lib/smith/workflow/wait_record.rb +157 -0
  201. data/lib/smith/workflow.rb +117 -19
  202. data/lib/smith.rb +103 -47
  203. metadata +88 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../diagnostic_text"
4
+
3
5
  module Smith
4
6
  class Agent
5
7
  # Monotonic timing and trace emission for one provider attempt: the
@@ -7,6 +9,9 @@ module Smith
7
9
  # (including any provider-side tool loop). Per-network-round timing
8
10
  # belongs to the host's RubyLLM notification subscriptions, not Smith.
9
11
  module ProviderCallTiming
12
+ EXCEPTION_CAUSE = Exception.instance_method(:cause)
13
+ private_constant :EXCEPTION_CAUSE
14
+
10
15
  class Timer
11
16
  def self.start
12
17
  new
@@ -38,7 +43,11 @@ module Smith
38
43
  # durations across entries. duration_ms is present only when the timed
39
44
  # provider call actually started: a failure before dispatch (model
40
45
  # resolution, chat construction) emits its attempt without a duration.
41
- def record_provider_call_trace(attempt, attempt_index, aborted: false)
46
+ # input_tokens and output_tokens total the attempt's usage entries, so
47
+ # a host can record the whole attempt when it ends. request_changed
48
+ # marks a batch result that answered the request sent, not the one the
49
+ # call renders now.
50
+ def record_provider_call_trace(attempt, aborted: false)
42
51
  Smith::Trace.record(
43
52
  type: :provider_call,
44
53
  data: {
@@ -46,12 +55,30 @@ module Smith
46
55
  provider: attempt.model_reference.provider,
47
56
  duration_ms: attempt.duration_ms,
48
57
  attempt_id: attempt.attempt_id,
49
- attempt_index: attempt_index,
50
- outcome: provider_call_outcome(attempt, aborted)
58
+ attempt_index: attempt.attempt_index,
59
+ outcome: provider_call_outcome(attempt, aborted),
60
+ **provider_call_error_classes(attempt.error),
61
+ agent_name: attempt.agent_name,
62
+ request_changed: attempt.request_changed,
63
+ memo: attempt.memo,
64
+ **provider_call_usage(attempt.usage)
51
65
  }.compact
52
66
  )
53
67
  end
54
68
 
69
+ # Token counts are metadata, never content, so the content policy
70
+ # leaves them in place.
71
+ def provider_call_usage(usage)
72
+ return {} unless usage
73
+
74
+ {
75
+ input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
76
+ cache_read_tokens: usage.cache_read_tokens, cache_write_tokens: usage.cache_write_tokens,
77
+ thinking_tokens: usage.thinking_tokens, stop_reason: usage.stop_reason,
78
+ provider_message_id: usage.provider_message_id, mode: usage.mode
79
+ }
80
+ end
81
+
55
82
  def provider_call_outcome(attempt, aborted)
56
83
  return :aborted if aborted
57
84
  return :success if attempt.success?
@@ -59,14 +86,51 @@ module Smith
59
86
  :failure
60
87
  end
61
88
 
89
+ # Class names only, never a message: the same bounded identifier the
90
+ # failed :transition trace carries, for the error and its direct cause.
91
+ def provider_call_error_classes(error)
92
+ return {} unless error
93
+
94
+ cause = provider_call_error_cause(error)
95
+ {
96
+ error_class: DiagnosticText.error_class_name(error),
97
+ error_cause_class: cause && DiagnosticText.error_class_name(cause)
98
+ }
99
+ end
100
+
101
+ # A transport error wraps what actually happened (never connected, or
102
+ # lost the connection after sending); Faraday keeps it as
103
+ # wrapped_exception when the error was not raised inside a rescue.
104
+ def provider_call_error_cause(error)
105
+ cause = EXCEPTION_CAUSE.bind_call(error)
106
+ cause ||= error.wrapped_exception if error.respond_to?(:wrapped_exception)
107
+ cause if cause.is_a?(Exception)
108
+ rescue StandardError
109
+ nil
110
+ end
111
+
112
+ def completed_provider_attempt(completion, observed_reference, timer, facts)
113
+ usage = ProviderUsage.sum(completion.provider_usages)&.with_response_facts(completion.response_facts)
114
+ attempt = ProviderAttempt.success(
115
+ completion:, model_reference: observed_reference, duration_ms: timer.elapsed_ms, usage:, **facts
116
+ )
117
+ record_provider_call_trace(attempt)
118
+ attempt
119
+ end
120
+
62
121
  # Aborted (non-provider) attempts emit a :provider_call too, so the
63
122
  # 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)
123
+ # have their join target; the error then propagates unchanged. A
124
+ # provider failure's own reported usage is accounted beside the
125
+ # completed prefix, so its trace totals both.
126
+ def failed_provider_attempt(error, observed_reference, timer, facts, prefix_usages)
127
+ aborted = !provider_failure?(error)
128
+ usages = aborted ? prefix_usages : [*prefix_usages, ProviderUsage.from_message(error)].compact
66
129
  attempt = ProviderAttempt.failure(
67
- error: error, model_reference: observed_reference, attempt_id:, duration_ms: timer&.elapsed_ms
130
+ error:, model_reference: observed_reference, duration_ms: timer&.elapsed_ms,
131
+ usage: ProviderUsage.sum(usages), **facts
68
132
  )
69
- record_provider_call_trace(attempt, attempt_index, aborted: !provider_failure?(error))
133
+ record_provider_call_trace(attempt, aborted:)
70
134
  attempt
71
135
  end
72
136
  end
@@ -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
- def complete_with_provider(agent_class, prepared_input, output_schema:)
19
- candidates = ProviderCandidateSequence.new(build_model_chain(agent_class))
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
- attempt = attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index: index)
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::AgentError,
54
+ raise Smith::WorkflowError,
37
55
  "no executable model candidate for #{agent_class}; declare a model or fallback_models"
38
56
  end
39
57
 
@@ -68,6 +86,8 @@ module Smith
68
86
  end
69
87
 
70
88
  def fallback_model_references(agent_class)
89
+ return agent_class.resolve_fallback_models(@context || {}) if agent_class.fallback_models_block
90
+
71
91
  Array(agent_class.fallback_models).map { |model| ModelReference.coerce(model) }
72
92
  end
73
93
 
@@ -86,54 +106,37 @@ module Smith
86
106
  # cohesive lifecycle (identity, prepared chat, observed model, timed
87
107
  # completion, prefix accounting on failure); splitting it would scatter
88
108
  # the rescue-path accounting away from what it accounts for.
89
- def attempt_model(agent_class, prepared_input, model_reference, output_schema:, attempt_index:)
90
- attempt_id = SecureRandom.uuid
91
- chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, output_schema:)
109
+ def attempt_model(agent_class, prepared_input, model_reference, shape:, attempt_index:)
110
+ facts = { attempt_id: SecureRandom.uuid, attempt_index:, agent_name: agent_class.register_as }
111
+ chat = prepared_attempt_chat(agent_class, prepared_input, model_reference, shape)
92
112
  message_count = chat_message_count(chat)
93
113
  observed_reference = observed_model_reference(chat, fallback: model_reference)
94
114
  timer = ProviderCallTiming::Timer.start
95
- response = chat.complete
115
+ response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
96
116
  timer.stop
97
- completion = Completion.from_messages(response: response, messages: new_chat_messages(chat, message_count))
117
+ completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
118
+ mode:)
98
119
 
99
- attempt = ProviderAttempt.success(
100
- completion:, model_reference: observed_reference, attempt_id:, duration_ms: timer.elapsed_ms
101
- )
102
- record_provider_call_trace(attempt, attempt_index)
103
- attempt
120
+ completed_provider_attempt(completion, observed_reference, timer, facts.merge(call_facts.to_h))
104
121
  rescue StandardError => e
105
122
  timer&.stop
106
123
  observed_reference ||= observed_model_reference(chat, fallback: model_reference)
107
- account_completed_prefix(agent_class, observed_reference, new_chat_messages(chat, message_count), attempt_id:)
108
- attempt = failed_provider_attempt(e, observed_reference, attempt_id, timer, attempt_index)
109
- raise unless provider_failure?(e)
124
+ prefix_usages = account_completed_prefix(
125
+ agent_class, observed_reference, new_chat_messages(chat, message_count),
126
+ attempt_id: facts[:attempt_id], stream_rounds: streamed_rounds(collected || [])
127
+ )
128
+ attempt = failed_provider_attempt(e, observed_reference, timer, facts, prefix_usages)
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 observed_model_reference(chat, fallback:)
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
- output_schema ? chat.with_schema(output_schema) : chat
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
- transient_failure?(error) ||
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,11 +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
48
+ end
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.
59
+ def self.sum(usages)
60
+ return if usages.empty?
61
+
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)
21
94
  end
22
95
 
23
96
  def total_tokens
24
- input_tokens + output_tokens
97
+ total_input_tokens + output_tokens
25
98
  end
26
99
  end
27
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.input_tokens + agent_result.output_tokens
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
- def emit_cost_trace(agent_result, invocation_cost)
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