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
@@ -15,23 +15,43 @@ module Smith
15
15
  def bridge_workflow_inputs(agent_class)
16
16
  return {} unless @context.is_a?(Hash)
17
17
 
18
- declared = agent_class.inputs || []
19
- user_declared = declared - Smith::Agent::RESERVED_INPUT_NAMES
18
+ user_declared = (agent_class.inputs || []) - Smith::Agent::RESERVED_INPUT_NAMES
19
+ reject_untrusted_inputs!(agent_class, user_declared)
20
20
  user_declared.to_h do |name|
21
21
  [name, @context[name]]
22
22
  end
23
23
  end
24
24
 
25
- def add_prepared_input(chat, prepared_input)
25
+ # A key the run's context, or that of a workflow it is nested in,
26
+ # marks untrusted never reaches an agent as a declared input, where it
27
+ # would sit beside the agent's instructions. Reserved input names are
28
+ # never read from the context, so they are not checked.
29
+ def reject_untrusted_inputs!(agent_class, declared)
30
+ untrusted = declared & untrusted_context_keys
31
+ return if untrusted.empty?
32
+
33
+ raise WorkflowError, "agent #{agent_class.register_as.inspect} declares untrusted context key " \
34
+ "#{untrusted.first.inspect} as an input"
35
+ end
36
+
37
+ # The messages, with their content parts (ContentParts) as `provider`
38
+ # takes them.
39
+ def add_prepared_input(chat, prepared_input, provider = nil)
26
40
  return unless prepared_input
27
41
 
28
42
  prepared_input = provider_safe_prepared_input(prepared_input)
29
- system_messages, other_messages = prepared_input.partition do |message|
30
- message_role(message) == :system
43
+ check_cache_breakpoints!(chat, prepared_input, provider)
44
+ with_built_contents do
45
+ prepared_input = prepared_input.map do |message|
46
+ Message.parts?(message) ? provider_parts_message(message, provider) : message
47
+ end
48
+ system_messages, other_messages = prepared_input.partition do |message|
49
+ message_role(message) == :system
50
+ end
51
+
52
+ merge_system_messages!(chat, system_messages) if system_messages.any?
53
+ other_messages.each { |message| add_message(chat, message) }
31
54
  end
32
-
33
- merge_system_messages!(chat, system_messages) if system_messages.any?
34
- other_messages.each { |message| add_message(chat, message) }
35
55
  end
36
56
 
37
57
  def provider_safe_prepared_input(prepared_input)
@@ -41,13 +61,16 @@ module Smith
41
61
  messages + [{ role: :user, content: WORKFLOW_CONTINUATION_MESSAGE }]
42
62
  end
43
63
 
64
+ # The input ends with the output the run last added to its transcript,
65
+ # so the provider gets a user turn after it rather than an assistant
66
+ # turn to continue.
44
67
  def workflow_handoff?(messages)
45
68
  message = messages.reverse_each.find { |candidate| message_role(candidate) != :system }
46
69
  return false unless message
47
70
  return false unless message_role(message) == :assistant
48
- return false unless defined?(@last_output) && !@last_output.nil?
71
+ return false unless defined?(@last_transcript_output) && !@last_transcript_output.nil?
49
72
 
50
- message_content(message) == @last_output
73
+ message_content(message) == @last_transcript_output
51
74
  end
52
75
 
53
76
  def merge_system_messages!(chat, prepared_system_messages)
@@ -93,12 +116,12 @@ module Smith
93
116
  # { text:, ...attachments } and opens each value as a file, so replaying a prior structured
94
117
  # output to the next agent in a workflow session raises Errno::ENOENT. Serialize non-string
95
118
  # content to JSON so the provider sees the prior output as text; the session store keeps the raw
96
- # value (last_output stays structured), only this provider-facing copy is serialized. Genuine
97
- # multimodal attachments are supplied through the provider's own with: mechanism, never as a bare
98
- # Hash message content in a workflow session.
119
+ # value (last_output stays structured), only this provider-facing copy is serialized. The only
120
+ # content that passes as it is: what Smith built from content parts for this very chat
121
+ # (ContentParts), never a RubyLLM object a session, output or host put there.
99
122
  def provider_safe_message(attributes)
100
123
  content = attributes[:content]
101
- return attributes if content.nil? || content.is_a?(String)
124
+ return attributes if content.nil? || content.is_a?(String) || built_content?(content)
102
125
 
103
126
  attributes.merge(content: json_message_content(content))
104
127
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "output_integrity"
3
4
  require_relative "provider_completion"
4
5
  require_relative "usage_tracking"
5
6
 
@@ -8,6 +9,7 @@ module Smith
8
9
  module Lifecycle
9
10
  include ProviderCompletion
10
11
  include UsageTracking
12
+ include OutputIntegrity
11
13
 
12
14
  private
13
15
 
@@ -18,12 +20,21 @@ module Smith
18
20
  instance.after_completion(result, context)
19
21
  end
20
22
 
21
- def invoke_agent(agent_class, prepared_input, output_schema: agent_class.output_schema)
23
+ # One invocation: the provider call, then whatever OutputIntegrity
24
+ # asks for until the response can be the output.
25
+ def invoke_agent(agent_class, prepared_input, output_schema: effective_output_schema(agent_class))
22
26
  check_deadline!
23
- attempt = complete_with_provider(agent_class, prepared_input, output_schema:)
24
- snapshot_and_finalize(
25
- agent_class, attempt.completion, attempt.model_reference, attempt_id: attempt.attempt_id
26
- )
27
+ plan = output_plan(agent_class, prepared_input, output_schema)
28
+ attempt = complete_with_provider(agent_class, prepared_input, shape: plan.shape)
29
+ loop do
30
+ result = account_attempt(agent_class, attempt.completion, attempt.model_reference,
31
+ attempt_id: attempt.attempt_id)
32
+ follow_up = output_follow_up(plan, attempt, result)
33
+ return finalize_output(agent_class, result) unless follow_up
34
+
35
+ settle_rejected(result)
36
+ attempt = follow_up.call
37
+ end
27
38
  end
28
39
  end
29
40
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # The model a chat actually holds (after RubyLLM resolved it), else the
6
+ # reference the attempt was made with.
7
+ module ObservedModel
8
+ private
9
+
10
+ def observed_model_reference(chat, fallback:)
11
+ model = observable_model(chat)
12
+ return fallback unless model.respond_to?(:id) && !model.id.to_s.empty?
13
+
14
+ ModelReference.coerce(model.id, provider: observed_provider(model, fallback))
15
+ rescue StandardError
16
+ fallback
17
+ end
18
+
19
+ def observable_model(chat)
20
+ resolved_chat = chat.respond_to?(:to_llm) ? chat.to_llm : chat
21
+ resolved_chat.model if resolved_chat.respond_to?(:model)
22
+ end
23
+
24
+ def observed_provider(model, fallback)
25
+ model.respond_to?(:provider) && !model.provider.to_s.empty? ? model.provider : fallback.provider
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # The problems an agent's `output_contract` finds in its output, as
6
+ # "path: problem" lines. A contract's `call(hash)` returns true or false,
7
+ # or a result answering `success?` and `errors`: a Hash of messages by
8
+ # key (nested as deep as the output), an Array of messages, or anything
9
+ # with `to_h` giving such a Hash (as dry-validation's errors do). A
10
+ # failure with no messages still counts.
11
+ module OutputContractCheck
12
+ FAILED = "/: fails the output contract"
13
+
14
+ module_function
15
+
16
+ def problems(contract, output)
17
+ return [] unless contract
18
+
19
+ verdict = contract.call(output)
20
+ return verdict ? [] : [FAILED] if [true, false].include?(verdict)
21
+ return [] if readable!(verdict).success?
22
+
23
+ lines = messages(verdict.respond_to?(:errors) ? verdict.errors : nil)
24
+ lines.empty? ? [FAILED] : lines.first(OutputFailure::MAX_PROBLEMS)
25
+ end
26
+
27
+ def readable!(verdict)
28
+ return verdict if verdict.respond_to?(:success?)
29
+
30
+ raise WorkflowError, "output_contract must return true, false, or a result answering success? and errors"
31
+ end
32
+
33
+ def messages(errors)
34
+ case errors
35
+ when Hash then lines(errors)
36
+ when Array then errors.map { |message| "/: #{message}" }
37
+ when nil then []
38
+ else errors.respond_to?(:to_h) && !errors.is_a?(String) ? lines(errors.to_h) : ["/: #{errors}"]
39
+ end
40
+ end
41
+
42
+ # { fields: { title: ["is missing"] } } becomes "/fields/title: is missing".
43
+ def lines(errors, path = "")
44
+ errors.flat_map do |key, value|
45
+ at = key.nil? ? path : "#{path}/#{key}"
46
+ next lines(value, at) if value.is_a?(Hash)
47
+
48
+ Array(value).map { |message| "#{at.empty? ? "/" : at}: #{message}" }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "output_contract_check"
6
+ require_relative "output_recovery"
7
+
8
+ module Smith
9
+ class Agent
10
+ # Accepting an agent's response as its output. A refused response takes
11
+ # the agent's refusal fallback once or raises OutputRefused; a cut-off
12
+ # one is retried once with a larger output limit or raises
13
+ # OutputTruncated (unless the agent accepts it). The response then goes
14
+ # through the agent's `after_completion`; a structured output that is
15
+ # not a JSON object, breaks a schema constraint or fails the agent's
16
+ # output contract is sent back for repair up to `output_repairs`, then
17
+ # raises OutputInvalid.
18
+ #
19
+ # No follow-up call is made once a tool has run in the step (the tools
20
+ # would run again), once the call has spent what its step reserved, or
21
+ # past MAX_CALLS; a follow-up that fails raises the output failure it
22
+ # was making up for, with the provider's error as its cause. Every
23
+ # response is billed; one that is set aside settles with the
24
+ # invocation's failed attempts, and an :output_rejected trace names it.
25
+ module OutputIntegrity
26
+ include OutputRecovery
27
+
28
+ # What one invocation has tried so far.
29
+ Plan = Struct.new(:agent_class, :input, :output_schema, :json_schema, :repairs_left, :retried, :fell_back,
30
+ :output_limit, :calls) do
31
+ def shape = ProviderRequest::Shape.new(output_schema:, output_limit:)
32
+ end
33
+ private_constant :Plan
34
+
35
+ private
36
+
37
+ # The agent's `output_schema`, else a static RubyLLM `schema` (a schema
38
+ # given as a block is resolved by RubyLLM per chat, and not checked).
39
+ def effective_output_schema(agent_class)
40
+ return agent_class.output_schema if agent_class.output_schema
41
+
42
+ schema = agent_class.schema if agent_class.respond_to?(:schema)
43
+ schema unless schema.is_a?(Proc)
44
+ end
45
+
46
+ def output_plan(agent_class, prepared_input, output_schema)
47
+ Plan.new(agent_class:, input: prepared_input, output_schema:, repairs_left: agent_class.output_repairs,
48
+ json_schema: output_schema && Providers::SchemaConstraints.json_schema(output_schema), calls: 1)
49
+ end
50
+
51
+ # nil when the output is accepted; otherwise a callable that makes the
52
+ # next attempt. Raises the output failure when nothing is left to try.
53
+ def output_follow_up(plan, attempt, result)
54
+ failure = stop_failure(plan, attempt, result) || content_failure(plan, attempt, result)
55
+ return unless failure
56
+
57
+ follow_up = recovery(plan, attempt, result, failure)
58
+ record_rejection(plan, attempt, failure, follow_up)
59
+ raise failure unless follow_up
60
+
61
+ follow_up
62
+ end
63
+
64
+ def stop_failure(plan, attempt, result)
65
+ case Providers::StopReason.classify(stop_reason_of(attempt))
66
+ when :refused then output_failure(OutputRefused, plan, attempt, result)
67
+ when :truncated
68
+ accepted = plan.agent_class.on_truncation[:policy] == :accept
69
+ output_failure(OutputTruncated, plan, attempt, result) unless accepted
70
+ end
71
+ end
72
+
73
+ # After the agent's `after_completion`, which sees every response the
74
+ # provider did not refuse or cut off. A blank output is left to
75
+ # BlankAgentOutputError.
76
+ def content_failure(plan, attempt, result)
77
+ result.content = run_after_completion(plan.agent_class, result.content, @context)
78
+ return if blank_agent_output?(result.content)
79
+
80
+ parse_structured_output!(plan, result)
81
+ problems = output_problems(plan, result.content)
82
+ output_failure(OutputInvalid, plan, attempt, result, problems) unless problems.empty?
83
+ end
84
+
85
+ # A structured output that reached Smith as JSON text (its schema
86
+ # parsing bypassed) is read as RubyLLM reads a schema response.
87
+ def parse_structured_output!(plan, result)
88
+ return unless (plan.output_schema || plan.agent_class.output_contract) && result.content.is_a?(String)
89
+
90
+ parsed = JSON.parse(result.content)
91
+ result.content = parsed if parsed.is_a?(Hash)
92
+ rescue JSON::ParserError
93
+ nil
94
+ end
95
+
96
+ def output_problems(plan, content)
97
+ contract = plan.agent_class.output_contract
98
+ return [] unless plan.output_schema || contract
99
+ return ["/: is not a JSON object"] unless content.is_a?(Hash)
100
+
101
+ schema = plan.json_schema && Providers::SchemaConstraints.problems(plan.json_schema["schema"], content)
102
+ Array(schema) + OutputContractCheck.problems(contract, content)
103
+ end
104
+
105
+ # A billed response that is not the invocation's output settles with
106
+ # its failed attempts.
107
+ def settle_rejected(agent_result)
108
+ add_failed_agent_results([agent_result])
109
+ Thread.current[:smith_last_agent_result] = nil
110
+ end
111
+
112
+ def record_rejection(plan, attempt, failure, follow_up)
113
+ Smith::Trace.record(
114
+ type: :output_rejected,
115
+ data: { agent: plan.agent_class.register_as, attempt_id: attempt.attempt_id,
116
+ error_class: failure.class.name, stop_reason: failure.stop_reason,
117
+ problem_count: failure.problems.length, follow_up: follow_up ? next_step(failure) : :none }
118
+ )
119
+ end
120
+
121
+ def next_step(failure)
122
+ case failure
123
+ when OutputRefused then :fallback
124
+ when OutputTruncated then :retry
125
+ else :repair
126
+ end
127
+ end
128
+
129
+ def stop_reason_of(attempt) = attempt.completion.response_facts[:stop_reason]
130
+
131
+ def output_failure(error_class, plan, attempt, result, problems = [])
132
+ error_class.new(agent_name: plan.agent_class.register_as, model_used: result.model_used,
133
+ stop_reason: stop_reason_of(attempt), problems:)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Agent
5
+ # What an agent does with a response that cannot be its output:
6
+ #
7
+ # on_truncation :fail # the default: OutputTruncated
8
+ # on_truncation :accept # keep the cut-off output
9
+ # on_truncation retry_with: { max_tokens: 32_000 }
10
+ # on_refusal :fail # the default: OutputRefused
11
+ # on_refusal fallback: "anthropic/claude-opus-5-5"
12
+ # output_contract ReviewContract # checks an output_schema agent's Hash
13
+ # output_repairs 1 # the default; 0 turns repair off
14
+ #
15
+ # A retry, a fallback and each repair are one more provider call, billed
16
+ # like any other.
17
+ module OutputPolicy
18
+ MAX_REPAIRS = 3
19
+ FAIL = { policy: :fail }.freeze
20
+ private_constant :FAIL
21
+
22
+ def on_truncation(policy = nil, retry_with: nil)
23
+ return @on_truncation || FAIL if policy.nil? && retry_with.nil?
24
+
25
+ @on_truncation = if one_of!("on_truncation", policy, %i[fail accept], retry_with)
26
+ { policy: :retry, max_tokens: retry_limit(retry_with) }.freeze
27
+ else
28
+ { policy: }.freeze
29
+ end
30
+ end
31
+
32
+ def on_refusal(policy = nil, fallback: nil)
33
+ return @on_refusal || FAIL if policy.nil? && fallback.nil?
34
+
35
+ @on_refusal = if one_of!("on_refusal", policy, %i[fail], fallback)
36
+ { policy: :fallback, model: refusal_fallback(fallback) }.freeze
37
+ else
38
+ FAIL
39
+ end
40
+ end
41
+
42
+ # Any object (or a class whose instances are such objects) whose
43
+ # `call(hash)` returns a result answering `success?` and `errors`, as
44
+ # a dry-validation contract does.
45
+ def output_contract(contract = nil)
46
+ return @output_contract if contract.nil?
47
+
48
+ checker = contract.is_a?(Class) ? contract.new : contract
49
+ raise ArgumentError, "output_contract must respond to call" unless checker.respond_to?(:call)
50
+
51
+ @output_contract = checker
52
+ end
53
+
54
+ def output_repairs(count = nil)
55
+ return @output_repairs || 1 if count.nil?
56
+ unless count.is_a?(Integer) && count.between?(0, MAX_REPAIRS)
57
+ raise ArgumentError, "output_repairs must be an Integer from 0 to #{MAX_REPAIRS}"
58
+ end
59
+
60
+ @output_repairs = count
61
+ end
62
+
63
+ private
64
+
65
+ # True when the option is given; otherwise the policy must be one of
66
+ # `policies`.
67
+ def one_of!(macro, policy, policies, option)
68
+ raise ArgumentError, "#{macro} takes a policy or an option, not both" if policy && option
69
+ return true if option
70
+ return false if policies.include?(policy)
71
+
72
+ raise ArgumentError, "#{macro} takes #{policies.map(&:inspect).join(" or ")}, or an option"
73
+ end
74
+
75
+ def retry_limit(options)
76
+ limit = options.is_a?(Hash) && options.keys == [:max_tokens] ? options[:max_tokens] : nil
77
+ return limit if limit.is_a?(Integer) && limit.positive?
78
+
79
+ raise ArgumentError, "on_truncation retry_with: takes { max_tokens: a positive Integer }"
80
+ end
81
+
82
+ def refusal_fallback(model)
83
+ reference = ModelReference.coerce(model)
84
+ return reference if reference.provider
85
+
86
+ raise ArgumentError, "on_refusal fallback: must name a provider, as in \"anthropic/<model>\""
87
+ rescue Dry::Struct::Error => e
88
+ raise ArgumentError, "invalid on_refusal fallback: #{e.message}"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Smith
6
+ class Agent
7
+ # The follow-up call an output failure may get (OutputIntegrity): a
8
+ # refusal fallback, a truncation retry, or a repair, each within the
9
+ # agent's settings and never once a tool has run in the step, once the
10
+ # call has spent what its step reserved, or past MAX_CALLS calls.
11
+ module OutputRecovery
12
+ MAX_CALLS = 3 + OutputPolicy::MAX_REPAIRS
13
+ REPAIR_NOTE = "Your previous reply could not be used as the required output:\n%s\n" \
14
+ "Reply again with output that fixes these problems."
15
+ REPAIR_TASK = "Reply with the required output."
16
+ # Why a streamed step's rejected text is withdrawn (Smith::Streaming).
17
+ STREAM_RESETS = { OutputRefused => :fallback, OutputTruncated => :truncation }.freeze
18
+ private_constant :MAX_CALLS, :REPAIR_NOTE, :REPAIR_TASK, :STREAM_RESETS
19
+
20
+ private
21
+
22
+ def recovery(plan, attempt, result, failure)
23
+ return if tools_ran? || plan.calls >= MAX_CALLS || budget_spent?(result)
24
+
25
+ case failure
26
+ when OutputRefused then refusal_recovery(plan, attempt, failure)
27
+ when OutputTruncated then truncation_recovery(plan, attempt, failure)
28
+ else repair_recovery(plan, attempt, result, failure)
29
+ end
30
+ end
31
+
32
+ def tools_ran? = Tool.current_tool_execution_tracker&.started? || false
33
+
34
+ def refusal_recovery(plan, attempt, failure)
35
+ fallback = plan.agent_class.on_refusal[:model]
36
+ return if fallback.nil? || plan.fell_back || fallback.same_candidate?(attempt.model_reference)
37
+
38
+ plan.fell_back = true
39
+ -> { follow_up_attempt(plan, attempt, fallback, failure) }
40
+ end
41
+
42
+ def truncation_recovery(plan, attempt, failure)
43
+ limit = retry_limit(plan, attempt)
44
+ return unless limit
45
+
46
+ plan.retried = true
47
+ plan.output_limit = limit
48
+ -> { follow_up_attempt(plan, attempt, attempt.model_reference, failure) }
49
+ end
50
+
51
+ # The larger limit to retry with, or nil: no retry is declared or left,
52
+ # the provider has no output limit Smith can set, the response ran out
53
+ # of context rather than output, or the limit is not larger than the
54
+ # one the request already had.
55
+ def retry_limit(plan, attempt)
56
+ return unless retry_allowed?(plan, attempt)
57
+
58
+ limit = plan.agent_class.on_truncation[:max_tokens]
59
+ current = plan.output_limit || Providers::OutputLimit.current(attempt.model_reference.provider,
60
+ agent_params(plan.agent_class),
61
+ attempt.model_reference)
62
+ limit if current.nil? || limit > current
63
+ end
64
+
65
+ def retry_allowed?(plan, attempt)
66
+ plan.agent_class.on_truncation[:policy] == :retry && !plan.retried &&
67
+ Providers::OutputLimit.supported?(attempt.model_reference.provider) &&
68
+ stop_reason_of(attempt) != "model_context_window_exceeded"
69
+ end
70
+
71
+ def agent_params(agent_class)
72
+ params = agent_class.params if agent_class.respond_to?(:params)
73
+ params.is_a?(Hash) ? params : {}
74
+ end
75
+
76
+ def repair_recovery(plan, attempt, result, failure)
77
+ return unless plan.repairs_left.positive?
78
+
79
+ plan.repairs_left -= 1
80
+ plan.input = repair_input(plan.input, result.content, failure.problems)
81
+ -> { follow_up_attempt(plan, attempt, attempt.model_reference, failure) }
82
+ end
83
+
84
+ # The step's messages as the provider got them (with any continuation
85
+ # turn), the rejected reply as the assistant's, and the problems.
86
+ def repair_input(input, content, problems)
87
+ note = format(REPAIR_NOTE, problems.map { |problem| "- #{problem}" }.join("\n"))
88
+ messages = provider_safe_prepared_input(Array(input))
89
+ messages = [{ role: :user, content: REPAIR_TASK }] if messages.empty?
90
+ messages + [{ role: :assistant, content: rejected_text(content) }, { role: :user, content: note }]
91
+ end
92
+
93
+ def rejected_text(content)
94
+ content.is_a?(String) ? content : JSON.generate(content)
95
+ rescue JSON::GeneratorError, Encoding::UndefinedConversionError
96
+ content.to_s
97
+ end
98
+
99
+ def follow_up_attempt(plan, previous, model_reference, pending)
100
+ check_deadline!
101
+ plan.calls += 1
102
+ withdraw_streamed(STREAM_RESETS.fetch(pending.class, :repair))
103
+ attempt = attempt_model(plan.agent_class, plan.input, model_reference,
104
+ shape: plan.shape, attempt_index: previous.attempt_index.to_i + 1)
105
+ return attempt if attempt.success?
106
+
107
+ fail_follow_up(plan, attempt, pending)
108
+ end
109
+
110
+ def fail_follow_up(plan, attempt, pending)
111
+ account_failed_attempt(attempt.error, attempt.model_reference, plan.agent_class, attempt_id: attempt.attempt_id)
112
+ raise pending, cause: attempt.error
113
+ end
114
+
115
+ # Whether the responses so far have reached what the call's step
116
+ # reserved in any budget dimension.
117
+ def budget_spent?(result)
118
+ reservation = Thread.current[:smith_call_reservation]
119
+ return false unless reservation
120
+
121
+ spent = extract_actuals([*Array(Thread.current[:smith_failed_agent_results]), result])
122
+ reservation.amounts.any? do |dimension, amount|
123
+ used = Workflow::BudgetIntegration::COST_DIMENSIONS.include?(dimension) ? spent[:cost] : spent[:tokens]
124
+ used >= amount
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -24,6 +24,12 @@ module Smith
24
24
  attribute :attempt_index, Types::Integer.optional.default(nil)
25
25
  attribute :agent_name, (Types::Symbol | Types::String).optional.default(nil)
26
26
  attribute :usage, Types.Instance(ProviderUsage).optional.default(nil)
27
+ # True for a batch result that answered a request other than the one
28
+ # the call renders now (BatchExchange); nil otherwise.
29
+ attribute :request_changed, Types::Bool.optional.default(nil)
30
+ # True for a response a host's response memo kept (ResponseMemo): the
31
+ # call sent nothing.
32
+ attribute :memo, Types::Bool.optional.default(nil)
27
33
 
28
34
  def self.success(completion:, model_reference:, **facts)
29
35
  new(completion:, model_reference:, error: nil, **facts)
@@ -44,7 +44,9 @@ module Smith
44
44
  # provider call actually started: a failure before dispatch (model
45
45
  # resolution, chat construction) emits its attempt without a duration.
46
46
  # input_tokens and output_tokens total the attempt's usage entries, so
47
- # a host can record the whole attempt when it ends.
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.
48
50
  def record_provider_call_trace(attempt, aborted: false)
49
51
  Smith::Trace.record(
50
52
  type: :provider_call,
@@ -57,6 +59,8 @@ module Smith
57
59
  outcome: provider_call_outcome(attempt, aborted),
58
60
  **provider_call_error_classes(attempt.error),
59
61
  agent_name: attempt.agent_name,
62
+ request_changed: attempt.request_changed,
63
+ memo: attempt.memo,
60
64
  **provider_call_usage(attempt.usage)
61
65
  }.compact
62
66
  )
@@ -67,7 +71,12 @@ module Smith
67
71
  def provider_call_usage(usage)
68
72
  return {} unless usage
69
73
 
70
- { input_tokens: usage.input_tokens, output_tokens: usage.output_tokens }
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
+ }
71
80
  end
72
81
 
73
82
  def provider_call_outcome(attempt, aborted)
@@ -101,9 +110,9 @@ module Smith
101
110
  end
102
111
 
103
112
  def completed_provider_attempt(completion, observed_reference, timer, facts)
113
+ usage = ProviderUsage.sum(completion.provider_usages)&.with_response_facts(completion.response_facts)
104
114
  attempt = ProviderAttempt.success(
105
- completion:, model_reference: observed_reference, duration_ms: timer.elapsed_ms,
106
- usage: ProviderUsage.sum(completion.provider_usages), **facts
115
+ completion:, model_reference: observed_reference, duration_ms: timer.elapsed_ms, usage:, **facts
107
116
  )
108
117
  record_provider_call_trace(attempt)
109
118
  attempt