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.
Files changed (193) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -0
  3. data/README.md +622 -15
  4. data/UPSTREAM_PROPOSAL.md +1 -0
  5. data/docs/CONFIGURATION.md +80 -14
  6. data/docs/PATTERNS.md +30 -13
  7. data/docs/PERSISTENCE.md +733 -5
  8. data/docs/TOOLS_AND_GUARDRAILS.md +24 -3
  9. data/lib/generators/smith/install/install_generator.rb +67 -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/invocation_preparation.rb +37 -14
  18. data/lib/smith/agent/lifecycle.rb +16 -5
  19. data/lib/smith/agent/observed_model.rb +29 -0
  20. data/lib/smith/agent/output_contract_check.rb +53 -0
  21. data/lib/smith/agent/output_integrity.rb +137 -0
  22. data/lib/smith/agent/output_policy.rb +92 -0
  23. data/lib/smith/agent/output_recovery.rb +129 -0
  24. data/lib/smith/agent/provider_attempt.rb +6 -0
  25. data/lib/smith/agent/provider_call_timing.rb +13 -4
  26. data/lib/smith/agent/provider_completion.rb +35 -32
  27. data/lib/smith/agent/provider_failure_handling.rb +5 -1
  28. data/lib/smith/agent/provider_request.rb +36 -0
  29. data/lib/smith/agent/provider_streaming.rb +155 -0
  30. data/lib/smith/agent/provider_usage.rb +70 -3
  31. data/lib/smith/agent/usage_entry_recording.rb +4 -2
  32. data/lib/smith/agent/usage_traces.rb +9 -2
  33. data/lib/smith/agent/usage_tracking.rb +64 -51
  34. data/lib/smith/agent.rb +5 -0
  35. data/lib/smith/artifacts/active_record_store.rb +79 -0
  36. data/lib/smith/artifacts/file.rb +45 -18
  37. data/lib/smith/artifacts/memory.rb +47 -36
  38. data/lib/smith/artifacts/s3.rb +126 -0
  39. data/lib/smith/artifacts/scoped_store.rb +55 -7
  40. data/lib/smith/artifacts/store_rules.rb +49 -0
  41. data/lib/smith/artifacts.rb +98 -1
  42. data/lib/smith/batch/message_shape.rb +36 -0
  43. data/lib/smith/batch.rb +166 -0
  44. data/lib/smith/budget/amount.rb +25 -0
  45. data/lib/smith/budget/continuation.rb +153 -0
  46. data/lib/smith/budget/host_ledger.rb +137 -0
  47. data/lib/smith/budget/ledger.rb +4 -2
  48. data/lib/smith/budget/ledger_state_transition.rb +7 -5
  49. data/lib/smith/budget/request_hold.rb +211 -0
  50. data/lib/smith/budget/spent_reservation.rb +18 -0
  51. data/lib/smith/child_run.rb +41 -0
  52. data/lib/smith/child_runs_failed.rb +56 -0
  53. data/lib/smith/content.rb +96 -0
  54. data/lib/smith/context/session.rb +14 -2
  55. data/lib/smith/context/state_injection.rb +10 -5
  56. data/lib/smith/context.rb +28 -0
  57. data/lib/smith/doctor/checks/artifacts.rb +103 -0
  58. data/lib/smith/doctor/checks/configuration.rb +0 -11
  59. data/lib/smith/doctor/checks/durability.rb +20 -18
  60. data/lib/smith/doctor/checks/state_model.rb +63 -0
  61. data/lib/smith/doctor/installer.rb +9 -2
  62. data/lib/smith/doctor.rb +4 -0
  63. data/lib/smith/errors.rb +249 -2
  64. data/lib/smith/idempotency_key/canonical.rb +74 -0
  65. data/lib/smith/idempotency_key.rb +145 -0
  66. data/lib/smith/launchers/inline.rb +46 -0
  67. data/lib/smith/message.rb +76 -0
  68. data/lib/smith/models/tool_routing.rb +47 -44
  69. data/lib/smith/output_failure.rb +130 -0
  70. data/lib/smith/persistence_adapters/active_record_exact_store.rb +43 -0
  71. data/lib/smith/pricing/catalog_validation.rb +91 -0
  72. data/lib/smith/pricing.rb +95 -64
  73. data/lib/smith/providers/anthropic/batches.rb +116 -0
  74. data/lib/smith/providers/batch_exchange.rb +77 -0
  75. data/lib/smith/providers/openai/responses.rb +45 -20
  76. data/lib/smith/providers/output_limit.rb +68 -0
  77. data/lib/smith/providers/request_holds.rb +139 -0
  78. data/lib/smith/providers/response_facts.rb +108 -0
  79. data/lib/smith/providers/schema_constraint_check.rb +141 -0
  80. data/lib/smith/providers/schema_constraints.rb +87 -0
  81. data/lib/smith/providers/schema_rules.rb +66 -0
  82. data/lib/smith/providers/stop_reason.rb +39 -0
  83. data/lib/smith/providers/stream_facts.rb +192 -0
  84. data/lib/smith/response_memo.rb +152 -0
  85. data/lib/smith/setting_constructors.rb +47 -0
  86. data/lib/smith/streaming/json_field.rb +136 -0
  87. data/lib/smith/streaming/json_string.rb +91 -0
  88. data/lib/smith/streaming/session.rb +77 -0
  89. data/lib/smith/streaming/think_tags.rb +67 -0
  90. data/lib/smith/streaming.rb +64 -0
  91. data/lib/smith/testing/checks.rb +58 -0
  92. data/lib/smith/testing/hooks.rb +85 -0
  93. data/lib/smith/testing/minitest.rb +37 -0
  94. data/lib/smith/testing/rspec.rb +52 -0
  95. data/lib/smith/testing/stub.rb +110 -0
  96. data/lib/smith/testing.rb +158 -0
  97. data/lib/smith/tool/scoped_context.rb +9 -12
  98. data/lib/smith/tool.rb +14 -0
  99. data/lib/smith/trace/logger.rb +1 -1
  100. data/lib/smith/trace/memory.rb +1 -1
  101. data/lib/smith/trace/open_telemetry.rb +1 -1
  102. data/lib/smith/trace.rb +11 -4
  103. data/lib/smith/untrusted.rb +74 -0
  104. data/lib/smith/version.rb +2 -2
  105. data/lib/smith/workflow/active_clock.rb +61 -0
  106. data/lib/smith/workflow/agent_result.rb +37 -0
  107. data/lib/smith/workflow/artifact_integration.rb +57 -7
  108. data/lib/smith/workflow/await_step.rb +42 -0
  109. data/lib/smith/workflow/batch_charges.rb +74 -0
  110. data/lib/smith/workflow/batch_execution.rb +149 -0
  111. data/lib/smith/workflow/batch_holds.rb +73 -0
  112. data/lib/smith/workflow/batch_wait.rb +134 -0
  113. data/lib/smith/workflow/budget_integration.rb +11 -5
  114. data/lib/smith/workflow/budget_pause.rb +51 -0
  115. data/lib/smith/workflow/child_run_declarations.rb +127 -0
  116. data/lib/smith/workflow/child_run_execution.rb +150 -0
  117. data/lib/smith/workflow/child_run_results.rb +55 -0
  118. data/lib/smith/workflow/composite/effects.rb +37 -6
  119. data/lib/smith/workflow/composite/error.rb +1 -1
  120. data/lib/smith/workflow/composite/error_evidence.rb +9 -9
  121. data/lib/smith/workflow/composite/input.rb +11 -4
  122. data/lib/smith/workflow/context_keys.rb +77 -0
  123. data/lib/smith/workflow/deadline_enforcement.rb +1 -1
  124. data/lib/smith/workflow/deterministic_execution.rb +61 -10
  125. data/lib/smith/workflow/deterministic_step.rb +55 -6
  126. data/lib/smith/workflow/dsl.rb +59 -0
  127. data/lib/smith/workflow/durability.rb +139 -30
  128. data/lib/smith/workflow/evaluator_optimizer.rb +12 -6
  129. data/lib/smith/workflow/event_integration.rb +9 -0
  130. data/lib/smith/workflow/execution.rb +30 -23
  131. data/lib/smith/workflow/execution_binding_resolution.rb +13 -0
  132. data/lib/smith/workflow/failure_reconstructor.rb +27 -4
  133. data/lib/smith/workflow/failure_record.rb +6 -1
  134. data/lib/smith/workflow/failure_record_validator.rb +11 -4
  135. data/lib/smith/workflow/fanout_execution.rb +5 -8
  136. data/lib/smith/workflow/graph/batch_diagnostic.rb +83 -0
  137. data/lib/smith/workflow/graph/branch_limit_diagnostic.rb +41 -0
  138. data/lib/smith/workflow/graph/budget_ledger_diagnostic.rb +126 -0
  139. data/lib/smith/workflow/graph/durable_retry_diagnostic.rb +50 -0
  140. data/lib/smith/workflow/graph/execution_successors.rb +16 -2
  141. data/lib/smith/workflow/graph/inherited_untrusted_diagnostics.rb +77 -0
  142. data/lib/smith/workflow/graph/isolated_input_diagnostic.rb +50 -0
  143. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +43 -4
  144. data/lib/smith/workflow/graph/output_key_diagnostic.rb +53 -0
  145. data/lib/smith/workflow/graph/output_schema_diagnostic.rb +49 -0
  146. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +1 -0
  147. data/lib/smith/workflow/graph/run_launcher_diagnostic.rb +30 -0
  148. data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +9 -3
  149. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +7 -3
  150. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +8 -1
  151. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +9 -2
  152. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +7 -4
  153. data/lib/smith/workflow/graph/stream_diagnostic.rb +95 -0
  154. data/lib/smith/workflow/graph/transition_contract.rb +19 -0
  155. data/lib/smith/workflow/graph/transition_contract_attributes.rb +25 -2
  156. data/lib/smith/workflow/graph/transition_contract_configurations.rb +24 -3
  157. data/lib/smith/workflow/graph/transition_diagnostics.rb +11 -24
  158. data/lib/smith/workflow/graph/transition_snapshot.rb +40 -39
  159. data/lib/smith/workflow/graph/untrusted_input_diagnostic.rb +57 -0
  160. data/lib/smith/workflow/graph/waiting_child_diagnostic.rb +24 -0
  161. data/lib/smith/workflow/graph.rb +12 -0
  162. data/lib/smith/workflow/guarded_step_execution.rb +48 -3
  163. data/lib/smith/workflow/message_batch.rb +6 -0
  164. data/lib/smith/workflow/message_roles.rb +25 -0
  165. data/lib/smith/workflow/message_value_normalizer.rb +2 -1
  166. data/lib/smith/workflow/nested_execution.rb +47 -4
  167. data/lib/smith/workflow/orchestrator_worker.rb +9 -3
  168. data/lib/smith/workflow/parallel_execution.rb +10 -0
  169. data/lib/smith/workflow/persistence.rb +136 -13
  170. data/lib/smith/workflow/remembered_calls.rb +92 -0
  171. data/lib/smith/workflow/retry_execution.rb +68 -9
  172. data/lib/smith/workflow/retry_wait.rb +134 -0
  173. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -0
  174. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +7 -0
  175. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +4 -1
  176. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +3 -1
  177. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +1 -0
  178. data/lib/smith/workflow/split_step_persistence/preparation.rb +5 -0
  179. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +1 -0
  180. data/lib/smith/workflow/step_completion.rb +36 -7
  181. data/lib/smith/workflow/step_context.rb +21 -0
  182. data/lib/smith/workflow/step_io.rb +132 -0
  183. data/lib/smith/workflow/step_stream.rb +52 -0
  184. data/lib/smith/workflow/thread_context_snapshot.rb +2 -0
  185. data/lib/smith/workflow/transition.rb +267 -13
  186. data/lib/smith/workflow/transition_actionability.rb +2 -0
  187. data/lib/smith/workflow/usage_entry.rb +24 -3
  188. data/lib/smith/workflow/wait_completion.rb +131 -0
  189. data/lib/smith/workflow/wait_execution.rb +180 -0
  190. data/lib/smith/workflow/wait_record.rb +157 -0
  191. data/lib/smith/workflow.rb +113 -17
  192. data/lib/smith.rb +101 -45
  193. 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
- 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
 
@@ -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, output_schema:, attempt_index:)
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, output_schema:)
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.complete
115
+ response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
98
116
  timer.stop
99
- 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:)
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), attempt_id: facts[:attempt_id]
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 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,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
- new(input_tokens: usages.sum(&:input_tokens), output_tokens: usages.sum(&:output_tokens))
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
- input_tokens + output_tokens
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.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
@@ -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
- return unless error.respond_to?(:input_tokens) && error.respond_to?(:output_tokens)
20
+ usage = ProviderUsage.from_message(error)
21
+ return unless usage
18
22
 
19
- input = error.input_tokens
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
- # Returns the provider usages it recorded, so the attempt's trace can
27
- # carry the same totals its usage entries hold.
28
- def account_completed_prefix(agent_class, model_reference, messages, attempt_id: nil)
29
- completion = Completion.from_messages(response: nil, messages: messages)
30
- record_completion_usage(agent_class, completion, :partial_attempt, model_reference, attempt_id:)
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
- def record_failed_usage(agent_class, model_reference, input_tokens, output_tokens, attempt_id: nil)
35
- model_reference = coerce_model_reference(model_reference)
36
- cost = Smith::Pricing.compute_cost(
37
- model: model_reference.model_id,
38
- provider: model_reference.provider,
39
- input_tokens:,
40
- output_tokens:
41
- )
42
- agent_result = Workflow::AgentResult.new(
43
- content: nil,
44
- input_tokens:,
45
- output_tokens:,
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
- def snapshot_and_finalize(agent_class, completion, model_reference, attempt_id: nil)
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 = Workflow::AgentResult.new(
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
- agent_result.content = run_after_completion(agent_class, agent_result.content, @context)
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 sum
97
- # still settles the budget because it is what was verifiably billed.
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
- emit_cost_trace(agent_result, invocation_cost) if fully_priced
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
- model = agent_result.model_used
110
- agent_result.cost = Smith::Pricing.compute_cost(
111
- model: model,
112
- provider: agent_result.provider_used,
113
- input_tokens: agent_result.input_tokens,
114
- output_tokens: agent_result.output_tokens
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)